/* ============================================================================= * M01_MasterData_UI_LogicLab_Copy.ts * 복사해서 만들기(PLAN 3-4 · 계약 7장) — 정본·자체 로직을 별칭 꼴 식으로 펴 보이고 * 줄을 고치거나 더한 뒤 자체 로직(GX)으로 저장. * * [본떠 만들기] → GET /logic/formula(별칭 폼) → 별칭 표 바꾸기·로직 부르기 갈래 고르기· * 식 줄 고치기·더하기 → [시험 계산](POST /logic/formula/preview 로 원문 되돌린 뒤 /calc 재사용) * → [저장](POST /logic/formula/save) → afterSave 로 새 키를 알림(정본은 손대지 않음). * ========================================================================== */ import { createButton, createInputField, createSelectField, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { copyLogic, fetchLogicFormula, previewLogicFormula, saveLogicFormula, type FormulaAlias, type FormulaLine, type LogicRow, } from "./M01_MasterData_UI_Logic_Api"; import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { fetchTables, type TableHead } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; const OWNERS = ["현장", "공용"] as const; const TABLE_GROUPS = ["소요량", "계수"] as const; const GROUP_LABEL: Record = { 중간: "중간", 호표: "호표", 덧줄: "덧줄", 결과: "결과", }; export interface CopyOptions { /** 본뜰 원본 키(정본·자체 둘 다) */ key: string; /** 저장 뒤 새 GX 키를 알림 — 밖(LogicLab)이 목록 새로고침·그 키 열기를 함 */ afterSave: (newKey: string) => void | Promise; } /** 자리표(`호표[0].수량` 등)로 로직 안 그 칸을 읽고 씀 — `master_copy.slots()` 와 같은 자리 */ function field(row: LogicRow, place: string): { get: () => string; set: (v: string) => void } { if (place === "결과.식") { return { get: () => row.결과?.식 ?? "", set: (v) => { if (row.결과) row.결과.식 = v; }, }; } const m = /^(호표|중간|덧줄)\[(\d+)\]\.(.+)$/.exec(place); if (!m) return { get: () => "", set: () => {} }; const [, group, idxStr, col] = m; const idx = Number(idxStr); const arr = row[group] as Record[] | undefined; return { get: () => (arr?.[idx] ? String(arr[idx][col] ?? "") : ""), set: (v) => { if (arr?.[idx]) arr[idx][col] = v; }, }; } /** 별칭 원문의 키만 갈아 끼움 — 「찾기(QF000421, …)」 → 「찾기(QF999999, …)」(인자는 그대로) */ function swapKey(raw: string, newKey: string): string { return raw.replace(/^(찾기|로직)\(\s*[A-Za-z0-9]+/, `$1(${newKey}`); } /** 표 바꾸기 모달 — 소요량·계수 두 그룹을 함께 찾음 */ function openTablePick(onPick: (table: TableHead) => void): void { const close = (): void => backdrop.remove(); const search = createInputField({ type: "search", placeholder: tl("Copy_PickSearch") }); const list = el("div", { className: "m01-logic__pick-list" }); const dialog = el("div", { className: "m01-logic__pick", attrs: { role: "dialog", "aria-label": tl("Copy_PickTitle") }, children: [ el("div", { className: "m01lab__modal-top", children: [ el("h3", { text: tl("Copy_PickTitle") }), createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }), ], }), search.root, list, ], }); const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); document.body.append(backdrop); search.input.focus(); let timer: number | undefined; const run = async (): Promise => { const q = search.input.value.trim(); const pages = await Promise.all( TABLE_GROUPS.map((group) => fetchTables(group, "", "", q, 1, 20)), ); const hits = pages.flatMap((p) => p.tables); list.replaceChildren( ...(hits.length ? hits.map((t) => { const b = el("button", { className: "m01-logic__pick-row m01-logic__pick-row--button", attrs: { type: "button" }, children: [ el("span", { text: `${t.이름 || t.키} · ${t.원문번호 || ""}` }), el("span", { className: "m01-logic__muted", text: t.키 }), ], }); b.addEventListener("click", () => { onPick(t); close(); }); return b; }) : [el("p", { className: "m01-logic__muted", text: tl("Copy_PickNone") })]), ); }; search.input.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(() => void run(), 250); }); void run(); } function aliasRow(a: FormulaAlias, onSwap: (raw: string) => void, sub: HTMLElement): HTMLElement { const about = a.종류 === "찾기" ? [a.밑이름 || a.참조, Object.keys(a.값칸).join("·")].filter(Boolean).join(" · ") : [a.밑이름 || a.참조, a.결과단위].filter(Boolean).join(" · "); return el("div", { className: "m01lab__box", children: [ el("div", { className: "m01-logic__section-head", children: [ el("strong", { text: a.이름 }), el("span", { className: "m01-logic__muted", text: about }), ], }), el("pre", { className: "m01lab__expr", text: a.원문 }), sub, ...(a.종류 === "찾기" ? [ createButton({ label: tl("Copy_Alias_Swap"), variant: "ghost", onClick: () => openTablePick((t) => onSwap(swapKey(a.원문, t.키))), }), ] : []), ], }); } function logicAliasControls( a: FormulaAlias, onForked: (raw: string) => void, ): { root: HTMLElement } { let forked = false; const original = a.원문; const status = el("span", { className: "m01-logic__muted" }); const refBtn = createButton({ label: tl("Copy_Alias_Ref"), variant: "ghost", onClick: () => { forked = false; a.원문 = original; refresh(); }, }); const forkBtn = createButton({ label: tl("Copy_Alias_Fork"), variant: "ghost", onClick: () => void doFork(), }); const refresh = (): void => { refBtn.disabled = !forked; forkBtn.disabled = forked; status.textContent = forked ? tl("Copy_Alias_Forked") : ""; }; const doFork = async (): Promise => { try { const got = await copyLogic(a.참조); forked = true; a.원문 = swapKey(original, got.key); onForked(a.원문); refresh(); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; refresh(); return { root: el("div", { children: [refBtn, forkBtn, status] }) }; } function lineBox(row: LogicRow, line: FormulaLine, onChange: () => void): HTMLElement { const f = field(row, line.자리); const box = el("textarea", { className: "m01-logic__input m01lab__qty" }); box.value = f.get(); box.rows = Math.max(1, Math.ceil(box.value.length / 60)); box.addEventListener("input", () => { f.set(box.value); onChange(); }); const tag = [GROUP_LABEL[line.갈래], line.비목].filter(Boolean).join(" · "); return el("div", { className: "m01lab__box", children: [ el("div", { className: "m01-logic__section-head", children: [ el("strong", { text: line.이름 || line.자리 }), el("span", { className: "m01-logic__tag", text: tag }), ...(line.단위 ? [el("span", { className: "m01-logic__muted", text: line.단위 })] : []), ], }), box, ], }); } function addExtra(row: LogicRow, onChange: () => void, redraw: () => void): void { row.덧줄 = row.덧줄 ?? []; row.덧줄.push({ 이름: tl("Copy_ExtraName"), 식: "0", 비목: "재료비" }); onChange(); redraw(); } /** 복사 화면을 전체 덮는 시트로 엶 */ export async function openCopyScreen(opt: CopyOptions): Promise { showLoadingOverlay(); let got; try { got = await fetchLogicFormula(opt.key); } catch (error) { hideLoadingOverlay(); showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); return; } hideLoadingOverlay(); const row: LogicRow = got.로직; const marks: FormulaAlias[] = got.별칭; const nameField = createInputField({ label: tl("Copy_Name"), value: `${row.이름} 복사본` }); const ownerField = createSelectField({ label: tl("Copy_Owner"), options: OWNERS.map((o) => ({ value: o, text: o === "현장" ? tl("Copy_Site") : tl("Copy_Shared"), })), value: "현장", }); const aliasHost = el("div", { className: "m01-logic__scroll" }); const linesHost = el("div", { className: "m01-logic__scroll" }); const calcHost = el("div", { className: "m01lab__calc" }); const values: Record = {}; let timer: number | undefined; const refreshCalc = (): void => { window.clearTimeout(timer); timer = window.setTimeout(() => void runPreview(), 300); }; const runPreview = async (): Promise => { try { const preview = await previewLogicFormula(row, marks); buildCalc(calcHost, { savedKey: null, file: got.file, row: preview.logic, dirty: () => true, values, onLines: () => undefined, }); } catch (error) { calcHost.replaceChildren( el("p", { className: "m01-logic__bad", text: error instanceof Error ? error.message : "" }), ); } }; const drawLines = (): void => { linesHost.replaceChildren(...got.줄.map((line) => lineBox(row, line, refreshCalc))); }; const drawAliases = (): void => { aliasHost.replaceChildren( ...marks.map((a) => { const sub = a.종류 === "로직" ? logicAliasControls(a, refreshCalc).root : el("span", { text: "" }); return aliasRow( a, (raw) => { a.원문 = raw; refreshCalc(); }, sub, ); }), ); }; drawLines(); drawAliases(); refreshCalc(); const close = (): void => backdrop.remove(); const onSave = async (): Promise => { showLoadingOverlay(); try { const saved = await saveLogicFormula({ 로직: row, 별칭: marks, owner: ownerField.select.value, 이름: nameField.input.value.trim() || row.이름, 본뜬키: opt.key, }); showToast(tl("Copy_Saved"), "success"); close(); await opt.afterSave(saved.key); } catch (error) { showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error"); } finally { hideLoadingOverlay(); } }; const sheet = el("div", { className: "m01-logic__pick m01copy__sheet", attrs: { role: "dialog", "aria-label": tl("Copy_Title") }, children: [ el("div", { className: "m01lab__modal-top", children: [ el("h3", { text: `${tl("Copy_Title")} — ${row.이름}(${opt.key})` }), createButton({ label: tl("Copy_Cancel"), variant: "ghost", onClick: close }), ], }), el("div", { className: "m01copy__head", children: [nameField.root, ownerField.root], }), el("section", { className: "m01-logic__section", children: [el("h3", { text: tl("Copy_Aliases") }), aliasHost], }), el("section", { className: "m01-logic__section", children: [ el("div", { className: "m01-logic__section-head", children: [ el("h3", { text: tl("Copy_Lines") }), createButton({ label: tl("Copy_AddExtra"), variant: "ghost", onClick: () => addExtra(row, refreshCalc, drawLines), }), ], }), linesHost, ], }), el("section", { className: "m01-logic__section", children: [calcHost] }), el("div", { className: "m01-logic__pick-bar", children: [ el("span", {}), el("span", {}), createButton({ label: tl("Copy_Save"), onClick: () => void onSave() }), ], }), ], }); const backdrop = el("div", { className: "m01-logic__backdrop", children: [sheet] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); document.body.append(backdrop); }