/* ============================================================================= * M01_MasterData_UI_LogicLab_New.ts * 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장) * ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름 * ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX) * 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`) * ========================================================================== */ import { createButton, createInputField, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { ApiError, draftLogic, type DraftAnswer, type DraftDecided, type DraftHead, type DraftProblem, type DraftVar, } from "./M01_MasterData_UI_Logic_Api"; import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var"; import "./M01_MasterData_UI_LogicLab_New.css"; /** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */ const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요 넓이 = 길이 * 폭 인부 = 넓이 * 품 잡재료비 = 노무비 * 0.05 공사비 = 인부 * 보통인부 + 잡재료비 `; export interface NewOptions { /** 저장 끝 — 새 자체 로직의 키 */ onSaved: (key: string) => void; onClose: () => void; } export function buildNewLogic(opts: NewOptions): HTMLElement { const decided: Record = {}; const rows = new Map(); const values: Record = {}; let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림 let answer: DraftAnswer | null = null; let calcSig = ""; let seq = 0; const text = el("textarea", { className: "m01lab-new__text", attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") }, }); text.value = SAMPLE; const stale = el("p", { className: "m01-logic__muted", text: tn("Stale"), attrs: { hidden: "" }, }); const varsHost = el("div", { className: "m01lab-new__vars" }); const problemHost = el("div", { className: "m01lab-new__problems" }); const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" }); const nameBox = createInputField({ type: "text", label: tn("Name"), onInput: () => onEdit(), }); nameBox.input.setAttribute("data-key", "name"); const unitBox = createInputField({ type: "text", label: tn("ResultUnit"), placeholder: "원/㎡", onInput: () => onEdit(), }); unitBox.input.setAttribute("data-key", "unit"); const ownerBox = createInputField({ type: "text", label: tn("Owner") }); const state = el("p", { className: "m01-logic__muted" }); const save = createButton({ label: tn("Save"), onClick: () => void doSave() }); save.setAttribute("data-act", "save"); save.disabled = true; const head = (): DraftHead | undefined => { const 이름 = nameBox.input.value.trim(); const 결과단위 = unitBox.input.value.trim(); return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined; }; /** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */ const drawProblems = (problems: DraftProblem[]): void => { if (!analyzed) return problemHost.replaceChildren(); const lines = analyzed.split("\n"); const flagged = new Map(); for (const p of problems) { if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]); } const shown = [...flagged.keys()] .sort((a, b) => a - b) .filter((n) => n <= lines.length) .map((n) => { const raw = lines[n - 1]; const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1; return el("div", { className: "m01lab-new__src", children: [ el("span", { className: "m01lab-new__no", text: String(n) }), at >= 0 ? el("code", { children: [ raw.slice(0, at), el("mark", { text: raw.slice(at, at + 1) }), raw.slice(at + 1), ], }) : el("code", { text: raw }), ], }); }); const items = problems.map((p) => el("li", { className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad", attrs: { "data-kind": p.갈래 }, text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`, }), ); problemHost.replaceChildren( el("h4", { text: tn("Problems") }), ...(items.length ? [...shown, el("ul", { children: items })] : [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]), ); }; const drawCalc = (): void => { const row = answer?.logic ?? null; save.disabled = !row || (answer?.문제.length ?? 1) > 0; state.textContent = !answer ? "" : answer.문제.some((p) => p.갈래 !== "덜정함") ? tn("SaveBlocked") : !head() ? tn("NeedHead") : answer.다됨 ? "" : tn("Undecided"); if (!row) { calcSig = ""; calcHost.replaceChildren(); return; } const sig = JSON.stringify(row.입력 ?? []); if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림 calcSig = sig; buildCalc(calcHost, { savedKey: null, row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄 dirty: () => true, values, onLines: () => undefined, }); }; const drawVars = (vars: DraftVar[]): void => { rows.clear(); const shown = vars.filter((v) => v.갈래 !== "비목합"); for (const name of Object.keys(decided)) { if (!shown.some((v) => v.이름 === name)) delete decided[name]; } const result = answer?.결과 ?? ""; const order = [ ...shown.filter((v) => v.갈래 === "미정"), ...shown.filter((v) => v.갈래 !== "미정"), ]; for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit })); varsHost.replaceChildren(...[...rows.values()].map((r) => r.root)); }; const evaluate = async (rebuild: boolean): Promise => { if (!analyzed) return; const mine = ++seq; try { const got = await draftLogic({ text: analyzed, decided, head: head() }); if (mine !== seq) return; // 최신 응답만 answer = got; if (rebuild) drawVars(got.변수); for (const v of got.변수) rows.get(v.이름)?.update(v); drawProblems(got.문제); drawCalc(); } catch (error) { showToast(error instanceof Error ? error.message : tn("Failed"), "error"); } }; let timer = 0; function onEdit(): void { window.clearTimeout(timer); timer = window.setTimeout(() => void evaluate(false), 250); } const analyzeButton = createButton({ label: tn("Analyze"), onClick: () => { analyzed = text.value; stale.hidden = true; void evaluate(true); }, }); analyzeButton.setAttribute("data-act", "analyze"); text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed)); async function doSave(): Promise { const h = head(); if (!h) return void showToast(tn("NeedHead"), "info"); save.disabled = true; showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림 try { const owner = ownerBox.input.value.trim(); const done = await draftLogic({ text: analyzed, decided, head: h, save: true, ...(owner ? { owner } : {}), }); showToast(tn("Saved", { v: done.key ?? "" }), "success"); if (done.key) opts.onSaved(done.key); } catch (error) { const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null; if (detail?.문제) drawProblems(detail.문제); else showToast(error instanceof Error ? error.message : tn("Failed"), "error"); save.disabled = false; } finally { hideLoadingOverlay(); } } const section = (title: string, ...children: HTMLElement[]): HTMLElement => el("section", { className: "m01lab__box m01lab-new__step", children: [el("h3", { text: title }), ...children], }); return el("div", { className: "m01lab-new", children: [ el("div", { className: "m01-logic__section-head", children: [ el("h2", { text: tn("Title") }), createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }), ], }), section( tn("Step1"), el("p", { className: "m01-logic__muted", text: tn("Guide") }), text, el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }), ), section(tn("Step2"), varsHost, problemHost), section( tn("Step3"), el("div", { className: "m01lab-new__row", children: [nameBox.root, unitBox.root, ownerBox.root], }), ), section(tn("Step4"), calcHost, state, save), ], }); }