/* ============================================================================= * B09_Estimation_UI_Link.ts * B09 설계내역서 내역 줄 ↔ 우리 공종 잇기 창(PLAN 707-4) — 불러온 경쟁사 줄(707-1 `hand:x…`)을 * 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본). * * 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) · * link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독 * 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) · * 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] · * [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장]) * 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 · * 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게) * 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { createListRow } from "@ui/ui_template_list_row"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { createToolbar } from "@ui/ui_template_toolbar"; import { openLogicPicker, type PickExtra, } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; import { currentProjectId } from "../A00_Common/b_project_switch"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; /** 받은 원가 자료 · 새 캐시를 화면에 — 불러오기 고리 = 페이지 `done` · 우클릭 = 탭 ctx */ export type LinkDone = (payload: Row, user: Row) => void; type Mode = "take" | "price"; const MODES = [ { value: "take", text: "가 넘겨받기" }, { value: "price", text: "나 단가만" }, ]; const HEAD = { earth: "토공", haul: "운반", st: "구조물" } as Record; const TONE: Record = { "지난번 이음": "success", "짝 표": "info" }; /** 마지막에 고른 수량 갈래(불러올 때 · 창에서) — 우클릭 길 기본 */ let lastMode: Mode = "take"; const flat = (t: unknown): string => String(t ?? "").replace(/\s+/g, ""); const label = (r: Row): string => [r.name, r.spec].filter(Boolean).join(" ") + (r.unit ? ` (${r.unit})` : ""); async function post(path: string, body: Row): Promise { const response = await fetch( `${API_BASE_URL}/projects/${currentProjectId()}/estimation/link/${path}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); const got = (await response.json().catch(() => null)) as Row | null; if (!response.ok || !got) { const detail = got?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); } return got; } const fail = (head: string) => (error: unknown) => showToast(`${head} — ${error instanceof Error ? error.message : error}`, "error"); /** 잇기 한 번 — 받은 것 화면에 → 새 캐시 · 알림 글 */ async function linkPairs(user: Row, done: LinkDone, pairs: Row[], counts: Row): Promise { const got = await post("apply", { pairs, mode: lastMode, counts, user }); done(got.payload, got.user); const n = got.알림 || {}; const miss: Row[] = n.못함 || []; const head = `이음 ${n.이음} · 묶음 ${n.묶음}${n.지움 ? ` · 안 줄 지움 ${n.지움}` : ""}`; const tail = (miss.length ? ` · 못함 ${miss.length}(${miss[0].까닭})` : "") + (n.남은 != null ? ` · 안 이은 불러온 항목 ${n.남은}` : ""); showToast(`${head}${tail} — [저장]해야 남음`, miss.length ? "warning" : "success", 6000); return got.user; } /** 우리 줄 고르기 — 단가연결 고르기 창(덧줄만 · 종류 = 토공 · 운반 · 구조물) */ function pickOurs(spots: Row[], line: Row, take: (key: string) => void): void { const group = line.kind !== "item"; const extra: PickExtra[] = spots .filter((s) => (s.head === "st") === group) .map((s) => ({ kind: String(s.head), key: String(s.key), tag: HEAD[s.head] ?? s.head, name: String(s.name ?? ""), sub: String(s.spec ?? ""), unit: String(s.unit ?? ""), onPick: () => take(String(s.key)), })); if (!extra.length) return void showToast(group ? "빈 구조물 줄 없음" : "빈 토공 · 운반 줄 없음", "warning"); const kinds = [...new Set(extra.map((x) => x.kind))]; openLogicPicker(() => undefined, { layer: "project", owner: null, libOnly: true, title: `우리 공종으로 잇기 — ${label(line)}`, extra, kindFilter: kinds.map((k) => ({ value: k, text: HEAD[k] ?? k })), pick: () => undefined, }); } /** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) */ export async function openBillLink(user: Row, done: LinkDone, mode?: string): Promise { if (mode === "take" || mode === "price") lastMode = mode; let cur = user; let seen: Row; try { seen = await post("suggest", { user: cur }); } catch (error) { return fail("제안을 못 받음")(error); } if (!seen.남은.length && !seen.이은.length) return; // 불러온 줄 없음 let busy = false; const run = (job: () => Promise): void => { if (busy) return; busy = true; job() .then((next) => { if (next) cur = next; return post("suggest", { user: cur }); }) .then((next) => ((seen = next), draw()), fail("잇기 못 함")) .finally(() => (busy = false)); }; const all = createButton({ label: "모두 제안대로", variant: "filled" }); const modeSel = createSelectField({ options: MODES, value: lastMode, compact: true, label: "수량 갈래(줄에서 바꿈)", onChange: (v) => (lastMode = v as Mode), }); const body = el("div", { className: "b09-import__choices" }); // 줄마다 고른 것(후보 · 갈래 · 개수) — 다시 그려도 남음 const chosen = new Map(); const pickOf = (s: Row) => { // 기본 = 앞 줄이 안 고른 첫 후보(암석절취 연암 · 보통암 → 절토(연암) · 암발파) const taken = new Set([...chosen.values()].map((c) => c.target)); const first = (s.후보 as Row[]).find((c) => !taken.has(c.key)) ?? s.후보[0]; const got = chosen.get(s.key) ?? { target: first.key, mode: null, count: "" }; chosen.set(s.key, got); return got; }; const pairOf = (s: Row): Row => { const p = pickOf(s); return { line: s.key, target: p.target, ...(p.mode && { mode: p.mode }) }; }; const countsOf = (lines: Row[]): Row => Object.fromEntries( lines .map((s) => [s.key, Number(pickOf(s).count)]) .filter(([, n]) => Number.isFinite(n) && (n as number) > 0), ); const forget = (key: string): void => run(async () => { const got = await post("forget", { key, user: cur }); showToast( got.남음 ? "이 짝 잊음 — 회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", got.남음 ? "warning" : "success", ); }); const suggestRow = (s: Row): HTMLElement => { const p = pickOf(s); const tags = [createBadge(s.꼬리표, TONE[s.꼬리표] ?? "neutral")]; if (s.후보.some((c: Row) => flat(c.name) === flat(s.name))) tags.push(createBadge("이름 같음", "accent")); if (!s.하나) tags.push(createBadge("고름", "warning")); const target = createSelectField({ options: s.후보.map((c: Row) => ({ value: c.key, text: `→ ${c.name}` })), value: p.target, compact: true, ariaLabel: "이을 우리 줄", onChange: (v) => (p.target = v), }); const lineMode = createSelectField({ options: [{ value: "", text: "창 갈래" }, ...MODES], value: p.mode ?? "", compact: true, ariaLabel: "이 줄 수량 갈래", onChange: (v) => (p.mode = (v || null) as Mode | null), }); const items: HTMLElement[] = [...tags, target.root, lineMode.root]; if (s.kind !== "item") { const count = createInputField({ type: "number", compact: true, min: 0, value: p.count, placeholder: "개수(B08)", onInput: (v) => (p.count = v), }); items.push(count.root); } items.push( createButton({ label: "잇기", variant: "ghost", onClick: () => run(() => linkPairs(cur, done, [pairOf(s)], countsOf([s]))), }), ); if (s.꼬리표 === "지난번 이음") items.push( createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(s.key) }), ); return createListRow({ label: label(s), sub: s.까닭 || (s.kind === "item" ? "" : "묶음 → 구조물 줄 통째"), trailing: createToolbar({ items, size: 24 }), }); }; const freeRow = (r: Row): HTMLElement => createListRow({ label: label(r), sub: r.kind === "item" ? "" : "묶음", trailing: createButton({ label: "우리 공종으로 잇기", variant: "ghost", size: 24, onClick: () => pickOurs(seen.자리, r, (target) => run(() => linkPairs(cur, done, [{ line: r.key, target }], {})), ), }), }); const linkedRow = (r: Row): HTMLElement => createListRow({ label: `${label(r.from)} → ${r.name}`, sub: HEAD[String(r.key).split(":")[0]] ?? "", trailing: createToolbar({ size: 24, items: [ createButton({ label: "잇기 풀기", variant: "ghost", onClick: () => run(() => undoLink(cur, done, r.key)), }), createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }), ], }), }); const draw = (): void => { const lines: Row[] = seen.줄; const has = new Set(lines.map((s) => s.key)); const free = (seen.남은 as Row[]).filter((r) => !has.has(r.key)); const items = (seen.남은 as Row[]).filter((r) => r.kind === "item").length; const recall = lines.filter((s) => s.꼬리표 === "지난번 이음").length; all.disabled = !lines.length; body.replaceChildren( createText( `안 이은 불러온 줄 — 항목 ${items} · 묶음 ${seen.남은.length - items} · 제안 ${lines.length}` + `(지난번 이음 ${recall} · 짝 표 ${lines.length - recall}) · 이은 줄 ${seen.이은.length}`, "muted", 14, ), createToolbar({ items: [modeSel.root] }), ...(lines.length ? [createText("제안", "muted", 14), ...lines.map(suggestRow)] : []), ...(free.length ? [createText("제안 없음", "muted", 14), ...free.map(freeRow)] : []), ...(seen.이은.length ? [createText("이은 줄", "muted", 14), ...seen.이은.map(linkedRow)] : []), ); }; openModal({ title: mode ? "다음: 우리 공종으로 잇기" : "우리 공종으로 잇기", closeLabel: "닫기", width: "960px", mount: (box, close, actions) => { all.dataset.b09LinkAll = ""; all.addEventListener("click", () => { const lines: Row[] = seen.줄; run(async () => { const used = new Set(); // 같은 우리 줄 둘째부터 = 남김(고름) const pairs = lines.map(pairOf).filter((p) => !used.has(p.target) && used.add(p.target)); const next = await linkPairs(cur, done, pairs, countsOf(lines)); close(); // 짧은 길 — 남은 줄은 알림 · 우클릭으로 return next; }); }); actions.append(all); draw(); box.append(body); }, }); } async function undoLink(user: Row, done: LinkDone, target: string): Promise { const got = await post("undo", { target, user }); done(got.payload, got.user); showToast("잇기 풂 — 불러온 줄로 · [저장]해야 남음", "success"); return got.user; } /** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 · 잊기 */ export function linkMenu(ctx: TabContext, key: string): MapContextMenuItem[] { const done: LinkDone = (payload, user) => { Object.assign(ctx.payload, payload); ctx.setUser(user); }; const entry = ((ctx.user.bill?.rows as Row[]) || []).find((e) => e.key === key); const forget = (): void => void post("forget", { key, user: ctx.user }).then( (got) => showToast(got.남음 ? "회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", "info"), fail("못 잊음"), ); if (entry?.was) return [ ["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))], ["이 짝 잊기", forget], ]; if (!key.startsWith("hand:x")) return []; return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]]; }