- 카드 · 편집이 그 구조물의 복사본(폴더 = 구조물 id · 관 = pipe@측점)을 읽고 고침 · 같은 항목을 고른 구조물끼리 값이 따로 - [재적용](덮이는 것을 확인 창으로 알리고 덮임 부분을 알림) · [개인에 저장](경로 · 이름 물음) · 상태 줄에 고침 · 마스터가 바뀜 · 원본 없음 표시 - 나무 개인 그룹 · 폴더 오른쪽 클릭 「새 항목」 → 돌려받은 summary_item 을 폼에 지정 · [적용] 뒤 [저장] 안내 한 줄 - B05 구조물 폼이 구조물 id 를 모달에 넘김 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_item_picker_project.ts
|
|
* 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름.
|
|
*
|
|
* 구조물 복사본(sub6 · 26-1 · 26-2b) — 폴더 = 구조물 id(같은 항목을 고른 구조물도 복사본은 따로)
|
|
* GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]}
|
|
* POST /api/projects/{id}/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
|
|
* POST /api/projects/{id}/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) → {…, summary_item}
|
|
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409)
|
|
* POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]}
|
|
* POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item}
|
|
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력)
|
|
* 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks`
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import {
|
|
fetchItemInputs,
|
|
type ItemInputs,
|
|
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
|
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
|
|
|
|
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
|
|
export type ProjectPart =
|
|
"도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재";
|
|
|
|
/** 원본 종류 → 복사본 부분 */
|
|
export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = {
|
|
structure: "도면",
|
|
area: "면적",
|
|
material: "재료",
|
|
};
|
|
|
|
export interface ProjectItemInfo {
|
|
폴더: string;
|
|
이름?: string | null;
|
|
원본: { layer: string; owner: string | null; key: string; name?: string };
|
|
원본판?: string | null;
|
|
가져온날?: string | null;
|
|
/** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */
|
|
마스터바뀜?: boolean;
|
|
원본없음?: boolean;
|
|
구조물?: string[];
|
|
고침?: boolean;
|
|
}
|
|
|
|
/** 구조물 id → 복사본 폴더 이름 — 서버 `folder_name` 과 같은 글(id 없는 옛 구조물 `#차례` = `n차례`) */
|
|
export const structureFolder = (structureId: string): string => structureId.replace("#", "n");
|
|
|
|
/** 관 지점의 구조물 id — 서버 `pipe@{round(측점, 3)}`(파이썬 float 글: 정수도 `100.0`) */
|
|
export function pipeStructureId(chainageM: number): string {
|
|
const rounded = Math.round(chainageM * 1000) / 1000;
|
|
return `pipe@${Number.isInteger(rounded) ? rounded.toFixed(1) : String(rounded)}`;
|
|
}
|
|
|
|
/** 이 복사본이 그 항목의 것인가 — 같은 구조물이 다른 항목으로 바뀌면 원본 참조가 다름 */
|
|
export const sameItem = (info: ProjectItemInfo, row: PickerRow): boolean =>
|
|
info.원본.layer === layerOf(row) &&
|
|
(info.원본.owner ?? null) === ownerOf(row) &&
|
|
info.원본.key === row.key;
|
|
|
|
/** 개인 층에 새로 생긴 항목 — `summary_item` 을 폼에 지정하면 됨 */
|
|
export interface CreatedItem {
|
|
key: string;
|
|
이름?: string;
|
|
summary_item: { layer: string; owner: string | null; key: string; name: string };
|
|
}
|
|
|
|
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */
|
|
export interface ProjectCalc {
|
|
total: number;
|
|
gaps: number;
|
|
stuck: number;
|
|
}
|
|
|
|
interface Reply {
|
|
status: number;
|
|
body: unknown;
|
|
}
|
|
|
|
const enc = encodeURIComponent;
|
|
|
|
/** 서버가 막은 까닭 그대로 — 문장 · `{message}`(409) · 입력 검사 목록(`[{msg}]`) */
|
|
function reasonOf(reply: Reply): string {
|
|
const detail = (reply.body as { detail?: unknown } | null)?.detail;
|
|
if (typeof detail === "string" && detail) return detail;
|
|
const message = (detail as { message?: unknown } | null)?.message;
|
|
if (typeof message === "string" && message) return message;
|
|
if (Array.isArray(detail)) {
|
|
const lines = detail
|
|
.map((one) => String((one as { msg?: unknown })?.msg ?? ""))
|
|
.filter(Boolean);
|
|
if (lines.length) return lines.join(" · ");
|
|
}
|
|
return `HTTP ${reply.status}`;
|
|
}
|
|
|
|
async function call(path: string, init: RequestInit = {}): Promise<Reply> {
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
credentials: "include",
|
|
...init,
|
|
headers: init.body ? { "Content-Type": "application/json" } : undefined,
|
|
});
|
|
return { status: response.status, body: await response.json().catch(() => null) };
|
|
}
|
|
|
|
const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) });
|
|
|
|
export interface ProjectItems {
|
|
/** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */
|
|
list: () => Promise<ProjectItemInfo[]>;
|
|
/** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */
|
|
read: (folder: string, part: ProjectPart) => Promise<unknown | null>;
|
|
/** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */
|
|
write: (folder: string, part: ProjectPart, doc: unknown) => Promise<void>;
|
|
/** 프로젝트로 미리 가져옴 */
|
|
importItem: (row: PickerRow, structureId: string) => Promise<ProjectItemInfo>;
|
|
/** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */
|
|
reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>;
|
|
/** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */
|
|
saveAs: (folder: string, path: string[], name: string) => Promise<CreatedItem>;
|
|
/** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */
|
|
newItem: (
|
|
structureId: string,
|
|
path: string[],
|
|
name: string,
|
|
unit: string,
|
|
) => Promise<CreatedItem>;
|
|
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
|
|
calc: (folder: string) => Promise<ProjectCalc | null>;
|
|
}
|
|
|
|
const clients = new Map<string, ProjectItems>();
|
|
|
|
/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */
|
|
export function projectItems(projectId: string): ProjectItems {
|
|
const kept = clients.get(projectId);
|
|
if (kept) return kept;
|
|
const base = `/projects/${enc(projectId)}/items`;
|
|
const versions = new Map<string, string | null>();
|
|
/** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */
|
|
const forget = (folder: string): void => {
|
|
for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key);
|
|
};
|
|
const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`;
|
|
|
|
const client: ProjectItems = {
|
|
list: async () => {
|
|
const reply = await call(base);
|
|
const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목;
|
|
if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply));
|
|
return rows;
|
|
},
|
|
read: async (folder, part) => {
|
|
const reply = await call(at(folder, part));
|
|
if (reply.status === 404) return null;
|
|
if (reply.status !== 200) throw new Error(reasonOf(reply));
|
|
const got = reply.body as { 판?: string | null; 내용?: unknown };
|
|
versions.set(`${folder}/${part}`, got.판 ?? null);
|
|
return got.내용 ?? null;
|
|
},
|
|
write: async (folder, part, doc) => {
|
|
const key = `${folder}/${part}`;
|
|
if (!versions.has(key)) await client.read(folder, part); // 고칠 판을 먼저 앎
|
|
const reply = await call(at(folder, part), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ 판: versions.get(key) ?? null, 내용: doc }),
|
|
});
|
|
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
|
versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null);
|
|
},
|
|
importItem: async (row, structureId) => {
|
|
const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key };
|
|
const reply = await call(base, post({ structure: structureId, ...ref }));
|
|
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
|
const got = reply.body as { 폴더: string; 가져온날?: string | null };
|
|
forget(got.폴더);
|
|
return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false };
|
|
},
|
|
reapply: async (info) => {
|
|
const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({}));
|
|
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
|
forget(info.폴더);
|
|
inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음
|
|
const got = reply.body as {
|
|
가져온날?: string | null;
|
|
원본판?: string | null;
|
|
덮임?: string[];
|
|
};
|
|
return {
|
|
info: {
|
|
...info,
|
|
가져온날: got.가져온날,
|
|
원본판: got.원본판,
|
|
마스터바뀜: false,
|
|
고침: false,
|
|
},
|
|
covered: got.덮임 ?? [],
|
|
};
|
|
},
|
|
saveAs: async (folder, path, name) => {
|
|
const reply = await call(
|
|
`${base}/${enc(folder)}/save-as`,
|
|
post({ to: "personal", 경로: path, 이름: name }),
|
|
);
|
|
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
|
return reply.body as CreatedItem;
|
|
},
|
|
newItem: async (structureId, path, name, unit) => {
|
|
const reply = await call(
|
|
`${base}/new`,
|
|
post({ structure: structureId, 경로: path, 이름: name, 단위: unit }),
|
|
);
|
|
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
|
return reply.body as CreatedItem;
|
|
},
|
|
calc: async (folder) => {
|
|
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
|
|
if (reply.status !== 200) return null;
|
|
const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] };
|
|
return {
|
|
total: Number(got.계 ?? 0),
|
|
gaps: got.빠진입력?.length ?? 0,
|
|
stuck: got.멈춤?.length ?? 0,
|
|
};
|
|
},
|
|
};
|
|
clients.set(projectId, client);
|
|
return client;
|
|
}
|
|
|
|
/* ── 입력 목록(원본 층 기준) — 같은 항목은 한 번만 받음 ───────────────────────── */
|
|
const inputsCache = new Map<string, Promise<ItemInputs>>();
|
|
|
|
/** 원본 항목의 입력 목록(로직마다 갈라 · 합치지 않음) — 못 받으면 다음에 다시 */
|
|
export function loadInputs(row: PickerRow): Promise<ItemInputs> {
|
|
const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
|
const kept = inputsCache.get(id);
|
|
if (kept) return kept;
|
|
const made = fetchItemInputs(layerOf(row), row.key);
|
|
inputsCache.set(id, made);
|
|
made.catch(() => inputsCache.delete(id));
|
|
return made;
|
|
}
|