/* ============================================================================= * M02_MasterTemplete_Hopyo_Block.ts * 엑셀 불러오기 단산 블록 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-5 · 2-6) — 줄 판 단산 줄 펼침 · 단가산출 탭 줄 판 한 몸. * · 블록 머리 = 블록 판정(기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 n) · 근거 표지 · 금액 참고(우리 · 엑셀) * · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 · * 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액 * · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것 * · 머리 [가져온 식으로 열기](793-3b · 새) = `Hopyo_Imported` 모달(이 블록 → 가져온 식 SK · 시트 · 단가 · 기초로 바꾸기) * · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버 * 판정 · 금액은 서버 · 여기는 보이기 · 고르기만. * ========================================================================== */ import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createPanelHead } from "@ui/ui_template_panel_head"; import { createTable, type Cell } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { formatNumber, formatPrice } from "../common_util/common_util_money"; import type { HopyoScope, Money } from "./M02_MasterTemplete_Hopyo_Api"; import { checkJudge, readJudgeBlock, type BaseCand, type CandMoney, type CheckOne, type CheckResult, type ExtraLine, type PairLine, type PickKind, type Section, type Solved, } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { bare, basePick, blockPicks, confirmable, draftKey, emptyDraft, missing, parseDraft, putBlock, samePick, setInput, togglePick, type DraftPick, type JudgeDraft, } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import "./M02_MasterTemplete_Hopyo_Judge.css"; /** 고름 캐시 손잡이 — 파일 하나 · sessionStorage(포트별) · 바뀌면 알림 */ export function createJudgeState(file: HopyoScope, key: string) { const k = draftKey(file.layer, file.owner, key); let d: JudgeDraft = parseDraft(sessionStorage.getItem(k)); const subs = new Set<() => void>(); return { file, key, get: (): JudgeDraft => d, set(next: JudgeDraft): void { d = next; sessionStorage.setItem(k, JSON.stringify(next)); subs.forEach((f) => f()); }, clear(): void { sessionStorage.removeItem(k); d = emptyDraft(); subs.forEach((f) => f()); }, on(f: () => void): () => void { subs.add(f); return () => subs.delete(f); }, }; } export type JudgeState = ReturnType; const TONE: Record = { "기초로 풂": "success", "기초+덧": "success", "줄 조합": "info", "고를 것": "warning", "기초 풂": "success", "후보 여럿": "warning", "기초 없음": "danger", "품 같음": "success", "품 다름": "warning", "입력 필요": "danger", 엑셀만: "info", 우리만: "neutral", 확인: "success", }; const badge = (text: string, at?: string): HTMLElement => { const b = createBadge(text, TONE[text] ?? "neutral"); if (at) b.dataset[at] = text; return b; }; const won = (v: number | null | undefined): string => v === null || v === undefined ? "—" : formatPrice(Math.floor(v)); const show = (v: unknown): string => v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v); const bar = (kids: HTMLElement[]): HTMLElement => el("div", { className: "ui-toolbar m02-judge__wrap", children: kids }); /** 금액 한 줄 — 노무 · 재료 · 경비 · 계(없으면 까닭) */ export function moneyText(m: CandMoney | Partial | null | undefined): string { if (!m) return "—"; if (m.계 === null && "까닭" in m) return String(m.까닭); const x = m as Partial; return `${won(x.노무비)} · ${won(x.재료비)} · ${won(x.경비)} = ${won(x.계)}`; } export interface SolvedBodyOptions { solved: Solved; picks: DraftPick[]; /** 고름 · 입력 바뀜 — 부르는 쪽이 캐시에 넣음 */ onPicks: (list: DraftPick[]) => void; /** 서버 검산(없으면 단추 안 보임) */ onCheck?: (list: DraftPick[], signal: AbortSignal) => Promise; /** 엑셀 보기 단가산출 상세 그 행 */ onSheet?: (행: number) => void; excel?: Partial | null; readOnly?: boolean; } /** 입력값 표 — 엔진 입력값 + 입력 필요 + 엇갈림 칸 */ function inputTable( opt: SolvedBodyOptions, cand: BaseCand, p: DraftPick, put: (p: DraftPick) => void, ): HTMLElement { const b = cand.바인딩; const names = [...new Set([...Object.keys(p.입력값), ...b.입력필요, ...Object.keys(b.엇갈림)])]; const need = new Set(missing(p)); const rows: Cell[][] = names.map((name) => { const src = p.출처[name]?.길; const tags: HTMLElement[] = []; if (src) { const tag = createBadge(src === "사용자" ? L("M02_JudgeUser") : src === "기본" ? L("M02_JudgeDefault") : src, src.startsWith("B") ? "accent" : "info"); // prettier-ignore tag.dataset.judgeSrc = src; if (p.출처[name]?.글) tag.title = String(p.출처[name]?.글); tags.push(tag); } if (need.has(name)) tags.push(badge(L("M02_JudgeNeed"), "judgeNeed")); const split = b.엇갈림[name]; if (split) tags.push(badge(L("M02_JudgeConflict"), "judgeConflict")); let field: HTMLElement; if (split && !opt.readOnly) { const picked = split.findIndex((s) => s.값 === p.입력값[name]); const sel = createSelectField({ options: [{ value: "", text: "—" }].concat(split.map((s, i) => ({ value: String(i), text: `${show(s.값)} (${s.길})` }))), // prettier-ignore value: picked < 0 ? "" : String(picked), compact: true, size: 24, ariaLabel: name, onChange: (v) => put(v === "" ? setInput(p, name, "") : setInput(p, name, split[+v].값, split[+v].길)), // prettier-ignore }); sel.select.dataset.judgeInput = name; field = sel.root; } else { const f = createInputField({ compact: true, size: 24, value: show(p.입력값[name]) }); f.input.dataset.judgeInput = name; f.input.readOnly = !!opt.readOnly; f.input.addEventListener("change", () => put(setInput(p, name, f.input.value))); if (need.has(name)) f.setError(L("M02_JudgeNeed")); field = f.root; } const dflt = b.기본[name]; if (need.has(name) && dflt !== undefined && dflt !== null && !opt.readOnly) { const use = createButton({ label: `${L("M02_JudgeDefault")} ${show(dflt)}`, size: 24, variant: "link", onClick: () => put(setInput(p, name, dflt, "기본")) }); // prettier-ignore use.dataset.judgeDefault = name; tags.push(use); } return [name, field, bar(tags)]; }); return createTable( [{ label: L("M02_JudgeColInput"), width: "9em" }, { label: L("M02_JudgeColValue"), width: "12em" }, { label: L("M02_JudgeColSource") }], // prettier-ignore rows, { density: "data" }, ); } /** 줄 대조 표 */ function pairTable(opt: SolvedBodyOptions, lines: PairLine[]): HTMLElement { const rows: Cell[][] = lines.map((x) => { const at = x.엑셀?.행; const jump = at && opt.onSheet ? createButton({ label: L("M02_JudgeSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(at) }) // prettier-ignore : ""; return [ x.엑셀?.묶음 ?? x.우리?.종류 ?? "", x.엑셀 ? `${x.엑셀.이름} ${show(x.엑셀.품)}` : "—", x.우리 ? `${x.우리.이름} ${show(x.우리.품)}${x.우리.까닭 ? ` · ${x.우리.까닭}` : ""}` : "—", bar([badge(x.판정, "judgePair"), ...(x.표지 ? [createBadge(x.표지, "neutral")] : [])]), jump, ]; }); return createTable( [ { label: L("M02_JudgeColGroup"), width: "48px" }, { label: L("M02_JudgeColExcel") }, { label: L("M02_JudgeColOurs") }, { label: L("M02_JudgeColPair") }, { label: L("M02_JudgeSheet"), width: "52px" }, ], rows, { density: "data" }, ); } /** 덧 줄 후보 → 고른 것 */ function extraPick(x: ExtraLine, c: NonNullable[number]): DraftPick { const 갈래: PickKind = c.자재 ? "재료" : c.노임 ? "인력" : "기계"; const 키 = c.자재 ?? c.노임 ?? c.키 ?? null; return { 갈래, 키, 이름: c.이름 ?? x.이름, 규격: c.규격 ?? "", 단위: c.단위 ?? "", 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore } /** 블록 풀이 몸 — 상태(고름 · 검산 결과)를 쥐고 바뀌면 다시 그림 */ export function solvedBody(opt: SolvedBodyOptions): { root: HTMLElement; set: (picks: DraftPick[]) => void; } { const root = el("div", { className: "m02-judge", attrs: { "data-judge-solved": String(opt.solved.번) }, }); let picks = opt.picks; let checked: Record = {}; let ask: AbortController | null = null; const put = (list: DraftPick[]): void => { picks = list; checked = {}; opt.onPicks(list); draw(); }; const swap = (old: DraftPick, next: DraftPick): void => put(picks.map((x) => (samePick(x, old) ? next : x))); async function check(): Promise { if (!opt.onCheck) return; ask?.abort(); ask = new AbortController(); try { const got = await opt.onCheck(picks, ask.signal); checked = Object.fromEntries(got.고른.map((c) => [String(c.키), c])); draw(); } catch (e) { if ((e as Error).name !== "AbortError") showToast((e as Error).message, "error"); } } function candBox(cand: BaseCand): HTMLElement | null { const p = picks.find((x) => x.갈래 === "기초" && x.키 === cand.키); if (!p) return null; const got = checked[cand.키]; const box = el("div", { className: "m02-judge__cand", attrs: { "data-judge-cand": cand.키 } }); const head = [createText(cand.이름, "muted"), badge(got?.판정 ?? cand.판정, "judgeVerdict")]; box.append(bar(head)); box.append(inputTable(opt, cand, p, (n) => swap(p, n))); if (cand.바인딩.안씀.length) box.append(createText(`${L("M02_JudgeUnused")} — ${cand.바인딩.안씀.join(" · ")}`, "hint")); box.append(pairTable(opt, got?.줄 ?? cand.줄)); const money = el("span", { text: `${L("M02_JudgeOursMoney")} ${moneyText(got?.금액 ?? cand.금액)}` }); // prettier-ignore money.dataset.judgeMoney = cand.키; box.append(bar([money])); return box; } function secBox(sec: Section, i: number): HTMLElement { const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-sec": String(i) } }); const title = `${L("M02_JudgeSection")} ${sec.번 ?? i + 1}. ${sec.제목}`; const chips: HTMLElement[] = sec.후보.map((c) => { const on = picks.some((x) => x.갈래 === "기초" && x.키 === c.키); const chip = createToggle({ kind: "chip", label: `${c.키} ${c.이름}`, checked: on, disabled: opt.readOnly, onChange: () => put(on ? picks.filter((x) => !(x.갈래 === "기초" && x.키 === c.키)) : [...picks, basePick(c, sec)]) }); // prettier-ignore chip.root.dataset.judgeBase = c.키; return chip.root; }); if (!chips.length) chips.push(createText(L("M02_JudgeNoBase"), "warning")); box.append(bar([createText(title, "muted", 14), badge(sec.판정, "judgeSec")]), bar(chips)); for (const c of sec.후보) { const cb = candBox(c); if (cb) box.append(cb); } return box; } function extraBox(): HTMLElement | null { if (!opt.solved.덧.length) return null; const rows: Cell[][] = opt.solved.덧.map((x) => { const chips = (x.후보 ?? []).slice(0, 4).map((c) => { const p = extraPick(x, c); const on = picks.some((y) => samePick(y, p)); const chip = createToggle({ kind: "chip", label: `${p.이름} ${p.규격 ?? ""}`.trim(), checked: on, disabled: opt.readOnly, onChange: () => put(togglePick(picks, p)) }); // prettier-ignore chip.root.dataset.judgeExtra = String(p.키); return chip.root; }); return [x.절, x.이름, x.방법 ?? "", bar(chips)]; }); const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-extra": "" } }); box.append(createText(L("M02_JudgeExtra"), "muted", 14)); box.append(createTable([{ label: L("M02_JudgeSection"), width: "8em" }, { label: L("M02_JudgeColExcel") }, { label: "" }, { label: L("M02_JudgeColPick") }], rows, { density: "data" })); // prettier-ignore return box; } function draw(): void { const s = opt.solved; const verdict = s.판정 === "고를 것" ? L("M02_JudgePickN").replace("{n}", String(s.고를수)) : s.판정; // prettier-ignore const v = createBadge(verdict, TONE[s.판정] ?? "neutral"); v.dataset.judgeBlockVerdict = s.판정; const ours = picks.reduce( (a, p) => { const c = s.절.flatMap((x) => x.후보).find((x) => x.키 === p.키); const m = (checked[String(p.키)]?.금액 ?? c?.금액) as Partial | undefined; return a === null || !m || m.계 === null || m.계 === undefined ? null : a + m.계; }, picks.length ? 0 : (null as number | null), ); const head: HTMLElement[] = [v, el("span", { text: `${L("M02_JudgeOursMoney")} ${won(ours)}`, attrs: { "data-judge-ours": "" } })]; // prettier-ignore if (opt.excel) head.push(el("span", { text: `${L("M02_JudgeExcelMoney")} ${moneyText(opt.excel)}`, attrs: { "data-judge-excel": "" } })); // prettier-ignore if (opt.onCheck) { const go = createButton({ label: L("M02_JudgeCheck"), size: 24, variant: "ghost", disabled: !picks.length, onClick: () => void check() }); // prettier-ignore go.dataset.judgeCheck = ""; head.push(go); } root.replaceChildren(bar(head), createText(L("M02_JudgeAmountNote"), "hint")); s.절.forEach((sec, i) => root.append(secBox(sec, i))); const extra = extraBox(); if (extra) root.append(extra); } draw(); return { root, set(list: DraftPick[]): void { picks = list; draw(); }, }; } export interface JudgeBlockOptions { host: HTMLElement; /** 파일 층 */ file: HopyoScope; key: string; /** 만들 층 */ target: HopyoScope; state: JudgeState; no: number; onSheet?: (행: number) => void; onClose?: () => void; readOnly?: boolean; } /** 단가산출 탭 줄 판 — 블록 하나 · [확인] = 그 블록 SK 로 옮길 것 */ export function createJudgeBlock(opt: JudgeBlockOptions): { root: HTMLElement; destroy: () => void; } { const root = el("section", { className: "m02-judge", attrs: { "data-judge-block": String(opt.no) }, }); root.append(createText(L("M02_JudgeLoading"), "hint")); opt.host.replaceChildren(root); let alive = true; void readJudgeBlock(opt.file, opt.key, opt.no, opt.target) .then((blk) => { if (!alive) return; const line = () => opt.state.get().블록[String(blk.번)]; const ok = createToggle({ kind: "chip", label: L("M02_JudgeConfirm"), checked: !!line()?.확인, disabled: opt.readOnly, onChange: (on) => { const list = blockPicks(opt.state.get(), blk); if (on && !confirmable(list)) { ok.set(false); showToast(`${L("M02_JudgeBlocked")} — ${list.flatMap(missing).join(" · ")}`, "warning"); return; } opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: on })); }, }); ok.root.dataset.judgeOk = "block"; const actions: HTMLElement[] = [ok.root]; const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly } })) }); // prettier-ignore open.dataset.judgeImported = String(blk.번); const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함) fresh.dataset.checkNew = ""; actions.push(open, fresh); if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`; const sub = [ blk.근거?.표지, blk.근거?.출처.map((x) => x.join(" ")).join(" · "), blk.부름.length ? `${L("M02_JudgeCalls")} ${blk.부름.map((n) => L("M02_JudgeHopyo").replace("{n}", String(n))).join(" · ")}` : "", // prettier-ignore blk.만든 ? `${L("M02_JudgeMade")} ${blk.만든}` : "", ].filter(Boolean).join(" · "); // prettier-ignore const body = solvedBody({ solved: blk, picks: blockPicks(opt.state.get(), blk), excel: blk.금액, readOnly: opt.readOnly, onSheet: opt.onSheet, onPicks: (list) => { opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: false })); ok.set(false); }, onCheck: (list, signal) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore }); root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, body.root); }) .catch((e: Error) => { if (alive) root.replaceChildren(createText(e.message, "error")); }); return { root, destroy(): void { alive = false; root.remove(); }, }; }