/* ============================================================================= * M02_MasterTemplete_Hopyo_View.ts * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기]. * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { openModal } from "@ui/ui_template_modal"; import { buildHopyo, deleteHopyo, guessHopyo, readHopyo, readHopyoOne, saveHopyoMemory, type HopyoBuilt, type HopyoGuess, type HopyoLine, type HopyoMemory, type HopyoOne, type HopyoScope, type HopyoTarget, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoViewOptions { scope: HopyoScope; /** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */ canWrite: boolean; /** 파일을 지웠음 — 좌측 목록 다시 */ onDeleted: () => void; } const STATE: Record = { 기억: "기억", 같음: "같음", 후보: "후보", 없음: "없음", 빼기: "뺌", 호표: "호표", "%": "% 줄", }; const MATERIAL = new Set(["자재", "별도계상"]); const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => el("div", { className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(), children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), body, ], }), ], }); const table = (heads: string[], rows: HTMLElement[]): HTMLElement => grid(heads, el("tbody", { children: rows })); const td = (text: string, cls = ""): HTMLElement => el("td", { text, className: cls, attrs: { title: text } }); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); /** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ export const targetText = (t: HopyoTarget | null | undefined): string => { if (!t) return ""; if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); const spec = t.규격 ? ` ${t.규격}` : ""; return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; }; /** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */ export const inputsText = (values: Record, unread: string[]): string => [ Object.entries(values) .map(([k, v]) => `${k}=${String(v)}`) .join(", "), unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "", ] .filter(Boolean) .join(" · "); /** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */ export const builtWord = (got: Pick, fresh: boolean): string => fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦"; /** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ export function memoryOf( target: HopyoTarget | null, values: Record, times: number | null, ): HopyoMemory { if (!target || times === null || !(times > 0)) return null; if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times }; if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times }; return null; } export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); let nav = 0; // 늦게 온 응답 버림 let query = ""; async function load(get: () => Promise, retry: () => void): Promise { const my = ++nav; const stop = showLoading(view); try { const got = await get(); return my === nav ? got : null; } catch (error) { if (my === nav) view.replaceChildren(loadingError(why(error), retry)); return null; } finally { stop(); } } /* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */ async function drawFile(): Promise { const file = await load( () => readHopyo(opt.scope, key), () => void drawFile(), ); if (!file) return; const search = el("input", { className: "ui-input ui-input--sm m02-hopyo__search", attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query }, }) as HTMLInputElement; const body = el("tbody"); const paint = (): void => { const q = search.value.trim().toLowerCase(); query = search.value; body.replaceChildren( ...file.호표 .filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q)) .map((h) => { const tr = el("tr", { className: "m02-hopyo__row", attrs: { "data-hopyo-no": String(h.번) }, children: [ td(`제${h.번}호표`), td(h.이름), td(h.규격), td(h.단위), td(won(h.금액.계), "m02-hopyo__num"), td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"), ], }); tr.addEventListener("click", () => void drawOne(h.번)); return tr; }), ); }; search.addEventListener("input", paint); paint(); const wrong = file.검산.어긋남; view.replaceChildren( el("div", { className: "m02-hopyo__bar", children: [ el("strong", { text: file.파일 }), note(`시트 ${file.시트}`), note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), ...(opt.canWrite ? [dropButton(file)] : []), ], }), ...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []), search, grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body), ); } const dropButton = (file: HopyoView): HTMLButtonElement => createButton({ label: "지우기", variant: "danger", size: "sm", onClick: async () => { const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`; if (!(await showConfirmDialog(ask, "지우기"))) return; try { await deleteHopyo(opt.scope, key); showToast("지움", "success"); opt.onDeleted(); } catch (error) { fail(error); } }, }); /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise { const got = await load( () => readHopyoOne(opt.scope, key, no), () => void drawOne(no), ); if (!got) return; const h = got.호표; const picked = new Map(); // 고른 대상(확정 전) const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" }); back.addEventListener("click", () => void drawFile()); const rows = el("tbody"); const paintRows = (): void => { rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows))); }; paintRows(); const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null); const tools: HTMLElement[] = []; if (opt.canWrite) { const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); all.disabled = !same.length; all.addEventListener("click", () => void confirmAll(no, same)); const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); make.addEventListener("click", () => void build(no, false)); tools.push(all, make); } const missing = h.줄.filter((x) => !x.맞춤.들어감); view.replaceChildren( el("div", { className: "m02-hopyo__bar", children: [ back, el("strong", { text: `제${h.번}호표 ${h.이름}` }), note(h.규격), note(h.단위), note(`엑셀 단가 ${won(h.금액.계)}`), note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), ...tools, ], }), h.줄.length ? grid( [ "갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산", "", ], rows, "m02-hopyo__lines", ) : el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }), ...(missing.length ? [missingBlock(missing)] : []), ...(built ? [builtBlock(no, built)] : []), ); } function lineRow( got: HopyoOne, line: HopyoLine, picked: Map, repaint: () => void, ): HTMLElement { const m = line.맞춤; const mine = picked.get(line.차례); const target = mine?.대상 ?? m.대상; const values = mine?.입력값 ?? m.입력값; const conv = mine?.환산 ?? m.환산; const fixed = ["%", "호표", "빼기"].includes(m.상태); const times = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "any", min: "0", value: conv?.배 != null ? String(conv.배) : "", }, }) as HTMLInputElement; times.disabled = !opt.canWrite || fixed || !target; const state = mine ? "고름" : (STATE[m.상태] ?? m.상태); const actions: HTMLElement[] = []; if (opt.canWrite && m.상태 === "빼기") { actions.push( createButton({ label: "되살림", variant: "ghost", size: "sm", onClick: () => void remember(got.호표.번, { [m.열쇠]: null }), }), ); } else if (opt.canWrite && !["%", "호표"].includes(m.상태)) { actions.push( createButton({ label: "고르기", variant: "ghost", size: "sm", onClick: () => choose(line, (t) => guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( (g) => (picked.set(line.차례, g), repaint()), fail, ), ), }), createButton({ label: "확정", size: "sm", disabled: !target, onClick: () => { const body = memoryOf(target, values, times.value === "" ? null : Number(times.value)); if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error"); void remember(got.호표.번, { [m.열쇠]: body }); }, }), createButton({ label: "빼기", variant: "ghost", size: "sm", onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }), }), ); } return el("tr", { className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`, attrs: { "data-line": String(line.차례), "data-state": m.상태 }, children: [ td(line.갈래), td(line.이름), td(line.규격), td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), td(line.단위), td(won(line.단가.계), "m02-hopyo__num"), el("td", { children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })], }), td(targetText(target)), td(inputsText(values, mine?.못읽음 ?? m.못읽음)), el("td", { children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")], }), el("td", { className: "m02-hopyo__acts", children: actions }), ], }); } /** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */ function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void { const material = MATERIAL.has(line.갈래); openModal({ title: `고르기 — ${line.이름} ${line.규격}`.trim(), closeLabel: "닫기", dialogClass: "m02-modal m02-hopyo__choose", mount: (body, close) => { const pick = (t: HopyoTarget): void => { close(); done(t); }; const list = (line.맞춤.후보 ?? []).map((c) => createButton({ label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, variant: "ghost", size: "sm", onClick: () => pick(c), }), ); const more = createButton({ label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", size: "sm", onClick: () => { close(); if (material) pickMaterial((x) => done({ 자재: x.코드 })); else openLogicPicker((x) => done({ 로직: x.키 })); }, }); body.append( el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), el("div", { className: "m02-hopyo__cands", children: list }), more, ); }, }); } async function remember(no: number, 줄: Record): Promise { try { await saveHopyoMemory(opt.scope, 줄); await drawOne(no); } catch (error) { fail(error); } } async function confirmAll(no: number, lines: HopyoLine[]): Promise { const body: Record = {}; for (const x of lines) { const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null); if (one) body[x.맞춤.열쇠] = one; } await remember(no, body); } async function build(no: number, fresh: boolean): Promise { try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; const got = await buildHopyo(opt.scope, from, fresh); const word = builtWord(got, fresh); const miss = got.빠진줄.length; showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success"); await drawOne(no, { ...got, 새로: fresh }); } catch (error) { fail(error); } } const missingBlock = (lines: HopyoLine[]): HTMLElement => el("div", { className: "m02-hopyo__block", children: [ el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }), table( ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"], lines.map((x) => el("tr", { children: [ td(x.이름), td(x.규격), td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), td(x.단위), td(won(x.금액.계), "m02-hopyo__num"), td(STATE[x.맞춤.상태] ?? x.맞춤.상태), ], }), ), ), ], }); function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement { const c = got.비교; const again = got.다시씀 ? [ note( got.새로 ? `사전조합 ${got.참조.키} 를 새로 만들어 덮음` : `있던 사전조합 ${got.참조.키} 를 다시 씀`, ), createButton({ label: "새로 만들기", variant: "danger", size: "sm", onClick: async () => { const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`; if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true); }, }), ] : [note(`새 사전조합 ${got.참조.키}`)]; return el("div", { className: "m02-hopyo__block m02-hopyo__built", attrs: { "data-built": got.참조.키 }, children: [ el("div", { className: "m02-hopyo__bar", children: again }), note( `값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, ), ], }); } void drawFile(); return view; }