/* ============================================================================= * M02_MasterTemplete_Hopyo_Rows.ts * 엑셀 불러오기 줄 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-3 · 2-4 · 2-6 · 분석 642-D 2-3 · 4) — 시트 아래 `[data-hopyo-lines]`. * · 호표 하나 = 줄마다 1차 판정 칩(제안 있음 · 2차 필요 · 후보 여럿 · 후보 없음) · 갈래 칩 · 근거 · * 후보 복수 고름(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · 기계 · 율 · 고정 — 한 줄에 여럿) · * 수량 · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [보기] = 엑셀 그 행 · [확인] * · 단산 줄 = [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸) + 「단가산출 통째」 칩(단산N → SK) * · 머리 [호표 제안 모두 확인](사용자 누름 · 다 풀린 제안만) · 아래 [옮기기](`createJudgeMove` · 오른쪽 판 아래) * · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈 * ========================================================================== */ import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { createButton, createInputField, 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 { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { formatNumber } from "../common_util/common_util_money"; import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block"; import { checkJudge, moveJudge, readJudgeOne, type JudgeOne, type JudgeRow, type MoveResult, } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { bare, confirmSuggested, confirmable, editLine, lineOf, linePicks, missing, moveBody, putLine, samePick, togglePick, type DraftPick, } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import { unitView } from "./logic/M02_MasterTemplete_Hopyo_Unit"; export { createJudgeState, type JudgeState }; const VERDICT: Record = { "제안 있음": "success", "2차 필요": "info", "후보 여럿": "warning", "후보 없음": "danger", }; const show = (v: unknown): string => v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v); const bar = (kids: (HTMLElement | string)[]): HTMLElement => el("div", { className: "ui-toolbar m02-judge__wrap", children: kids }); export type SheetRole = "일위대가표" | "단가산출상세"; export interface JudgeRowsOptions { host: HTMLElement; /** 파일 층 */ file: HopyoScope; key: string; /** 만들 층 */ target: HopyoScope; state: JudgeState; no: number; /** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */ onSheet?: (행: number, role: SheetRole) => void; onClose?: () => void; readOnly?: boolean; } const COLS: Column[] = [ { label: L("M02_JudgeColRow"), width: "52px" }, { label: L("M02_JudgeColName") }, { label: L("M02_JudgeColVerdict"), width: "84px" }, { label: L("M02_JudgeColKind") }, { label: L("M02_JudgeColPick") }, { label: L("M02_JudgeColQty"), align: "end" }, { label: L("M02_JudgeColOk"), width: "72px" }, ]; /** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */ const wholeSk = (row: JudgeRow): DraftPick => ({ 갈래: "단가산출", 키: `단산${row.번}`, 이름: row.이름 ?? `단산${row.번}`, 규격: row.규격 ?? "", 단위: row.단위 ?? "", 입력값: {}, 출처: {}, 몫: null, }); /** 시트 아래 줄 판 — 호표 하나 */ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; destroy: () => void } { const root = el("section", { className: "m02-judge", attrs: { "data-judge-rows": String(opt.no) } }); // prettier-ignore root.append(createText(L("M02_JudgeLoading"), "hint")); opt.host.replaceChildren(root); let one: JudgeOne | null = null; const open = new Set(); const bodies = new Map>(); let alive = true; const no = opt.no; const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row); const edit = (row: JudgeRow, list: DraftPick[]): void => opt.state.set(editLine(opt.state.get(), no, row, list)); function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement { const kids: HTMLElement[] = []; if (row.판정 === "2차 필요" && row.번 !== undefined) { const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); draw(); } }); // prettier-ignore solve.root.dataset.judgeSolve = String(row.번); const sk = wholeSk(row); const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore whole.root.dataset.judgeWhole = String(sk.키); kids.push(solve.root, whole.root); for (const p of picks.filter((x) => x.갈래 === "기초")) { const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent"); b.dataset.judgePicked = String(p.키); kids.push(b); } } else { const seen = [...row.후보]; for (const p of picks) if (!seen.some((x) => samePick(x, p))) seen.push(p); for (const c of seen) { const on = picks.some((p) => samePick(p, c)); const label = `${c.갈래} ${c.이름}${c.규격 ? ` ${c.규격}` : ""}`; const chip = createToggle({ kind: "chip", label, checked: on, disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, { ...c })) }); // prettier-ignore chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`; chip.root.title = c.키 ?? ""; kids.push(chip.root); } if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning")); } return bar(kids); } function qtyCell(row: JudgeRow, picks: DraftPick[]): HTMLElement { const line = lineOf(opt.state.get(), no, row.차례); const 배 = line?.배 ?? row.수량.배; const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore const text = `${show(row.수량.엑셀)}${배 !== null && 배 !== 1 ? ` × ${show(배)}` : ""}`; if (view.mode !== "input" || opt.readOnly) return bar([text]); const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore f.input.dataset.judgeTimes = String(row.차례); f.input.addEventListener("change", () => { const n = Number(f.input.value.replace(/,/g, "")); const next = f.input.value.trim() === "" || !Number.isFinite(n) ? null : n; opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next })); }); if (배 === null) f.setError(L("M02_JudgeNeed")); return bar([text, L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]); } function okCell(row: JudgeRow, picks: DraftPick[]): HTMLElement { const line = lineOf(opt.state.get(), no, row.차례); const ok = createToggle({ kind: "chip", label: line?.확인 ? L("M02_JudgeConfirmed") : L("M02_JudgeConfirm"), checked: !!line?.확인, disabled: opt.readOnly, onChange: (on) => { if (on && !confirmable(picks)) { ok.set(false); const why = picks.length ? picks.flatMap(missing).join(" · ") : L("M02_JudgeNoCand"); showToast(`${L("M02_JudgeBlocked")} — ${why}`, "warning"); return; } opt.state.set(putLine(opt.state.get(), no, row.차례, { ...line, 고른: picks, 확인: on })); }, }); ok.root.dataset.judgeOk = String(row.차례); return ok.root; } function rowCells(row: JudgeRow): Cell[] { const picks = picksOf(row); const jump = row.행 !== null && opt.onSheet ? createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }) // prettier-ignore : show(row.행); if (jump instanceof HTMLElement) jump.dataset.judgeRow = String(row.행); const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral"); v.dataset.judgeVerdict = row.판정; const kinds = row.갈래.map((k) => { const b = createBadge(k, "neutral"); b.dataset.judgeKind = k; return b; }); const name = [row.이름, row.규격, row.단위 ? `(${row.단위})` : ""].filter(Boolean).join(" "); return [ jump, name, v, bar([...kinds, row.근거]), chips(row, picks), qtyCell(row, picks), okCell(row, picks), ]; } /** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */ const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출"; const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p)); function solved(row: JudgeRow): HTMLElement { let body = bodies.get(row.차례); if (!body) { body = solvedBody({ solved: row.풀이!, picks: inner(row), readOnly: opt.readOnly, excel: row.단가, onSheet: opt.onSheet ? (r) => opt.onSheet!(r, "단가산출상세") : undefined, onPicks: (list) => edit(row, [...picksOf(row).filter(isSk), ...list]), onCheck: (list, signal) => checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore }); bodies.set(row.차례, body); } else body.set(inner(row)); return body.root; } function draw(): void { if (!one) return; const all = createButton({ label: L("M02_JudgeConfirmAll"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, one!.줄)), }); all.dataset.judgeAll = String(no); const actions: HTMLElement[] = [all]; if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore const title = `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${one.이름}`; const sub = [one.규격, one.단위, one.만든 ? `${L("M02_JudgeMade")} ${one.만든}` : ""].filter(Boolean).join(" · "); // prettier-ignore const { root: table, tbody } = tableShell(COLS, { density: "data" }); for (const row of one.줄) { const tr = tableRow(COLS, rowCells(row)); tr.dataset.judgeLine = String(row.차례); tbody.append(tr); if (row.풀이 && open.has(row.차례)) tbody.append(tableRow(COLS, [{ content: solved(row), colSpan: COLS.length }])); } root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, table); } const off = opt.state.on(draw); void readJudgeOne(opt.file, opt.key, no, opt.target) .then((got) => { if (!alive) return; one = got; draw(); }) .catch((e: Error) => { if (alive) root.replaceChildren(createText(e.message, "error")); }); return { root, destroy(): void { alive = false; off(); root.remove(); }, }; } export interface JudgeMoveOptions { file: HopyoScope; key: string; target: HopyoScope; state: JudgeState; readOnly?: boolean; onMoved?: (r: MoveResult) => void; } /** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */ export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } { const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore btn.dataset.judgeMove = ""; const root = bar([btn]); const count = (): void => { const n = moveBody(opt.state.get(), opt.file).수; btn.textContent = `${L("M02_JudgeMove")} (${n})`; btn.disabled = !!opt.readOnly || n === 0; }; async function go(): Promise { const { body, 막힘, 수 } = moveBody(opt.state.get(), opt.file); if (!수) { showToast(L("M02_JudgeMoveNone"), "info"); return; } btn.disabled = true; try { const r = await moveJudge(opt.target, opt.key, body); const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`)); let d = opt.state.get(); for (const [h, lines] of Object.entries(body.호표)) for (const at of Object.keys(lines)) if (!stuck.has(`${h}:${at}`)) { const { [at]: _gone, ...rest } = d.호표[h] ?? {}; d = { ...d, 호표: { ...d.호표, [h]: rest } }; } for (const b of Object.keys(body.블록)) if (!stuck.has(`b${b}:`)) { const { [b]: _gone, ...rest } = d.블록; d = { ...d, 블록: rest }; } opt.state.set(d); const msg = L("M02_JudgeMoved") .replace("{a}", String(r.만듦.length)) .replace("{b}", String(r.단가산출.length)) .replace("{c}", String(r.막힘.length + 막힘.length)); showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success"); opt.onMoved?.(r); } catch (e) { showToast((e as Error).message, "error"); } finally { count(); } } const off = opt.state.on(count); count(); return { root, destroy(): void { off(); root.remove(); }, }; }