From 5655c628b92364804aa3e8d261c5e2c8e9014e14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 17:21:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C):=20=EB=82=B4=EC=97=AD=20=EC=A4=84=20=E2=86=94=20?= =?UTF-8?q?=EC=9A=B0=EB=A6=AC=20=EA=B3=B5=EC=A2=85=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=E2=80=94=20=EB=AA=A8=EB=91=90=20=EC=A0=9C=EC=95=88?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=A4=84=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=C2=B7=20=EC=9E=87=EA=B8=B0=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=9D=B4=20=EC=A7=9D=20=EC=9E=8A=EA=B8=B0?= =?UTF-8?q?=20(PLAN=20707-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 화면 B09_Estimation_UI_Link — 제안(지난번 이음 · 짝 표 · 이름 같음 꼬리표 · 후보 고름) · 수량 갈래(불러올 때 고른 것 · 줄에서 바꿈) · 묶음 개수 칸 · 제안 없는 줄 = 단가연결 고르기 창 재사용(우리 토공 · 운반 · 구조물 줄만) · 이은 줄 풀기 · 잊기 · 남은 안 이은 줄 수 - 고리 둘 — 불러오기 끝 = 잇기 창 이어 뜸 · 설계내역서 우클릭 = 잇기 · 풀기 · 잊기 - 「고름」 줄 기본 = 앞 줄이 안 쓴 후보 · 모두 제안대로 = 같은 우리 줄 둘째부터 남김 - apply 알림에 안 이은 불러온 항목 수 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Router_Link.py | 11 +- B09_Estimation/B09_Estimation_UI_Import.ts | 2 + B09_Estimation/B09_Estimation_UI_Link.ts | 325 ++++++++++++++++++ .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 2 + 4 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Link.ts diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py index 8ccb754c9..d7f27a368 100644 --- a/B09_Estimation/B09_Estimation_Router_Link.py +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -4,7 +4,7 @@ → {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) · 저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)} POST /api/projects/{id}/estimation/link/apply - {pairs [{line, target, mode?}], auto, mode, counts?, user?} + {pairs [{line, target, mode?}], auto, mode, counts?, user?} · 알림 남은 = 안 이은 항목 수 잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 → {user(새 캐시), 알림, payload(그 캐시로 셈)} · 이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층) @@ -137,11 +137,10 @@ async def apply( made, report = linker.link(draft, rows, pairs, body.mode, body.counts, modes) made = store.clean_user(made) layer = await asyncio.to_thread(keep, session, report.pop("기억")) - return { - "user": made, - "알림": {**report, "기억층": layer}, - "payload": await payload(project_id, root, made, True), - } + out = await payload(project_id, root, made, True) + left = linker.board(made, out["sheets"]["bill"]["rows"])["남은"] + report["남은"] = sum(1 for r in left if r["kind"] == "item") + return {"user": made, "알림": {**report, "기억층": layer}, "payload": out} @router.post(PATH + "/undo") diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index cfe1070fe..56e1efe3e 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -31,6 +31,7 @@ import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { currentProjectId } from "../A00_Common/b_project_switch"; import type { Row } from "./B09_Estimation_Calc_Base"; +import { openBillLink } from "./B09_Estimation_UI_Link"; import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR"); @@ -135,6 +136,7 @@ export function createBillImport( // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); done(got.payload, merged(draft, got.user)); + void openBillLink(user(), done, mode.value); // 707-4 다음: 우리 공종으로 잇기 const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; diff --git a/B09_Estimation/B09_Estimation_UI_Link.ts b/B09_Estimation/B09_Estimation_UI_Link.ts new file mode 100644 index 000000000..bd87dca85 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Link.ts @@ -0,0 +1,325 @@ +/* ============================================================================= + * 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)]]; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 1f102dad6..d802bdf02 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -26,6 +26,7 @@ import { import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; +import { linkMenu } from "./B09_Estimation_UI_Link"; /** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */ export type BillTable = TabTable & { @@ -269,6 +270,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯) parts[1].push(...picks, ...more); } + parts[1].push(...linkMenu(ctx, key)); // 707-4 내역 줄 ↔ 우리 공종 잇기 · 풀기 · 잊기 // 옮김 · 레벨 — 같은 부모 형제와 자손째 if (!fixed(key)) { add(2, "위로 ↑", (w, e) => {