/* ============================================================================= * M02_MasterTemplete_Hopyo_Check.ts * 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) — * 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶. * · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 · * 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기] * · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit` * · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 · * 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만) * · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음 * · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈 * · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`) * · 단산 줄 [가져온 식으로 열기](793-3b) = `Hopyo_Imported` 모달(가져온 식 SK) · [이 줄에 잇기] = 그 줄 고른 것 = 그 SK * · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로) * · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음 * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { formatPrice } from "../common_util/common_util_money"; import { call } from "./M02_MasterTemplete_Api_Fetch"; import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { checkJudge, readJudgeBlock, readJudgeOne, type JudgeOne, type JudgeRow, type Pick, } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { checkTables, newBadge, NO_LOGIC, type LinePrice, type PriceLine, } from "./M02_MasterTemplete_Hopyo_CheckRows"; import { createRowKit, runMove, type JudgeState, type SheetRole, } from "./M02_MasterTemplete_Hopyo_Rows"; import { openImported } from "./M02_MasterTemplete_Hopyo_Imported"; import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { bare, confirmSuggested, lineOf, moveBody, samePick, setInput, type DraftPick, } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import "./M02_MasterTemplete_Hopyo.css"; const enc = encodeURIComponent; const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); const note = (text: string): HTMLElement => createText(text, "muted"); const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore /** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */ const priceJudge = ( target: HopyoScope, file: HopyoScope, key: string, no: number, 줄: Record, ): Promise<{ 줄: Record }> => call(at(target, `/hopyo/${enc(key)}/price`), { method: "POST", body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore }); export interface CheckOptions { /** 실무자료 파일 층 */ file: HopyoScope; key: string; /** 사전조합 · 구조물 조합을 만들 층 */ target: HopyoScope; state: JudgeState; no: number; /** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */ brief?: HopyoBrief; canWrite: boolean; /** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */ onSheet: (행: number, role: SheetRole) => void; onHopyo: () => void; /** 옮긴 뒤(목록 · 칠 다시 읽음) */ onMoved: () => void; } /** [수정] 모달 */ export function openHopyoCheck(opt: CheckOptions): { close: () => void } { const readOnly = !opt.canWrite; const no = opt.no; const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore let one: JudgeOne | null = null; let shut: () => void = () => {}; let off: () => void = () => {}; let busy = false; const leave = (): void => { off(); shut(); }; // ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ── const priced = new Map(); let queued: Record = {}; let seq = 0; let timer = 0; let pending = false; function soon(): void { if (pending) return; pending = true; window.setTimeout(() => { pending = false; if (root.isConnected) draw(); }, 0); } function flush(): void { const ask = queued; queued = {}; const my = ++seq; for (const k of Object.keys(ask)) { const slot = priced.get(Number(k)); if (slot) slot.seq = my; } priceJudge(opt.target, opt.file, opt.key, no, ask).then( (got) => { for (const k of Object.keys(ask)) { const slot = priced.get(Number(k)); const line = got.줄[k]; if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") }; } soon(); }, (e: unknown) => { for (const k of Object.keys(ask)) { const slot = priced.get(Number(k)); if (slot?.seq === my) slot.val = { fail: why(e) }; } soon(); }, ); } function price(row: JudgeRow): LinePrice | null { const picks = kit.picksOf(row); if (!picks.length) return null; const 배 = lineOf(opt.state.get(), no, row.차례)?.배; const body = picks.map((p) => bare(p, 배)); const sig = JSON.stringify(body); const have = priced.get(row.차례); if (have?.sig === sig) return have.val; priced.set(row.차례, { sig, seq: -1, val: { wait: true } }); queued[String(row.차례)] = body; window.clearTimeout(timer); timer = window.setTimeout(flush, 250); return { wait: true }; } const kit = createRowKit({ file: opt.file, key: opt.key, state: opt.state, no, readOnly, redraw: () => draw(), onSheet: (r, role) => { leave(); opt.onSheet(r, role); }, }); /** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */ async function showSource(row: JudgeRow): Promise { const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean); const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : [])); const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) => x.출처 ? [`${x.로직} — ${x.출처}`] : [], ); openModal({ title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`, closeLabel: L("Common_Btn_Close"), wide: true, dialogClass: "m02-modal m02-hopyo__src", mount: (body) => { if (row.근거) body.append(note(row.근거)); const part = (title: string, list: string[], cut: (s: string) => string): void => { body.append(el("h4", { text: title })); if (!list.length) body.append(note(L("M02_CheckNone"))); for (const s of list) { const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore view.show(cut(s)); body.append(note(s), view.root); } }; part(L("M02_CheckSourceExcel"), excel, (s) => s); part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — ")); }, }); } /** 기초 하나 바꿔 넣음(같은 것 자리) */ const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void => kit.edit( row, kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)), ); /** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */ function options(row: JudgeRow, p: DraftPick): void { fetchLogicInputs([String(p.키)]).then( (res) => openLaborInput({ title: `${p.키} ${p.이름}`.trim(), logic: res.로직[0] ?? null, values: p.입력값 as never, memo: null, readOnly, done: (r) => { let next = p; const got = r.입력값 as Record; for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)])) if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? ""); swap(row, p, next); }, }), (e: unknown) => showToast(why(e), "error"), ); } /** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */ function pick(row: JudgeRow): void { openLogicPicker((logic) => { const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then( (res) => { const c = res.고른[0]; const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] }; kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]); }, (e: unknown) => showToast(why(e), "error"), ); }); } /** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */ const imported = (row: JudgeRow): void => openImported({ ...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite }, onLink: (p) => kit.edit(row, [p]), }); /** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */ function out(row: JudgeRow, value: string): void { if (value === NO_LOGIC) { kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore return; } const d = opt.state.get(); const lines = { ...(d.호표[String(no)] ?? {}) }; delete lines[String(row.차례)]; opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } }); } /** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */ async function make(): Promise { const all = moveBody(opt.state.get(), opt.file); const mine = all.body.호표[String(no)] ?? {}; const n = Object.keys(mine).length; if (!n) return void showToast(L("M02_JudgeMoveNone"), "info"); const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return; busy = true; draw(); const stuck = all.막힘.filter((x) => x.호표 === no); const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck); busy = false; if (r) { document.dispatchEvent(new Event(HOPYO_BUILT)); opt.onMoved(); await load(true); } else draw(); } function draw(): void { if (!one) return; const h = one; const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length; const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore all.dataset.judgeAll = String(no); const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore go.dataset.hopyoMake = ""; const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore item.dataset.hopyoToItem = ""; item.title = h.만든 ? "" : L("M02_CheckToItemHint"); const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore sheet.dataset.hopyoSheet = ""; const head = el("div", { className: "ui-toolbar m02-hopyo__bar m02-judge__wrap", children: [ el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }), note(h.규격), note(h.단위), note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`), note(L("M02_CheckMoneyHint")), note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore ...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []), newBadge(), all, go, item, sheet, ], }); const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out, imported }; // prettier-ignore root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore } async function load(quiet = false): Promise { const stop = quiet ? () => {} : showLoading(root); try { one = await readJudgeOne(opt.file, opt.key, no, opt.target); draw(); } catch (e) { if (!quiet) root.replaceChildren(loadingError(why(e), () => void load())); } finally { stop(); } } openModal({ title: L("M02_CheckTitle").replace("{n}", String(no)), closeLabel: L("Common_Btn_Close"), wide: true, dialogClass: "m02-modal m02-hopyo__check-modal", beforeClose: () => { off(); return true; }, mount: (body, close) => { shut = close; body.append(root); }, }); off = opt.state.on(() => draw()); void load(); return { close: leave }; }