/* ============================================================================= * M02_MasterTemplete_Hopyo_Check.ts * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄. * · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기] * · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로) * · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) · * 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭 * · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈 * · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음 * · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만. * ========================================================================== */ import { createButton, el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { openModal } from "@ui/ui_template_modal"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { buildHopyo, guessHopyo, readHopyoOne, type BuildLine, type BuildTarget, type HopyoBuilt, type HopyoLine, type HopyoMatch, type HopyoOne, type HopyoPart, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; import { builtWord, inputsText, openHopyoChoose, sourceText, targetText, } from "./M02_MasterTemplete_Hopyo_Choose"; import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; /** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */ export const HOPYO_BUILT = "m02-hopyo-built"; const OUT = ["우리에 없음", "안 씀"] as const; /** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */ export interface CheckDraft { 체크: Record; 대상: Record; 뺌: Record; 품: Record; 통째?: boolean; } const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} }); export const draftKey = (scope: HopyoScope, key: string, no: number): string => `m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`; export function loadDraft(k: string): CheckDraft { try { const raw = sessionStorage.getItem(k); return raw ? { ...empty(), ...(JSON.parse(raw) as Partial) } : empty(); } catch { return empty(); } } export function saveDraft(k: string, d: CheckDraft): void { try { sessionStorage.setItem(k, JSON.stringify(d)); } catch { /* 캐시 못 씀 = 이 화면에만 남음 */ } } export function dropDraft(k: string): void { try { sessionStorage.removeItem(k); } catch { /* 없음 */ } } /** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */ export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => { const got = d.대상[line.차례] ?? line.맞춤.대상; return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음 }; /** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */ export const isChecked = (line: HopyoLine, d: CheckDraft): boolean => d.체크[line.차례] ?? !!line.맞춤.체크; /** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */ export function checkable(line: HopyoLine, d: CheckDraft): boolean { if (line.맞춤.상태 === "%") return false; if (d.뺌[line.차례]) return true; const parts = partsOf(line, d); return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0); } /** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */ export function toTarget(p: HopyoPart): BuildTarget { if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 }; const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 }; return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) }; } /** 체크한 줄만 → 만들기 몸 `줄` */ export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] => lines .filter((l) => isChecked(l, d) && checkable(l, d)) .map((l) => d.뺌[l.차례] ? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] } : { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) }, ); /** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */ export function leftOut(lines: HopyoLine[], d: CheckDraft): Record { const out: Record = {}; for (const l of lines) { const why = l.맞춤.상태 === "%" ? "% 줄" : !isChecked(l, d) || !checkable(l, d) ? "확인 안 함" : d.뺌[l.차례] ? "뺌" : null; if (why) out[why] = (out[why] ?? 0) + 1; } return out; } const n4 = (v: number | undefined): string => v === undefined ? "–" : String(Math.round(v * 10000) / 10000); /** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */ export function compareText(q: HopyoMatch["품"] | undefined): string { if (!q) return ""; const ex = q.엑셀.인력 ?? {}; const ours = q.우리.인력 ?? {}; const mark = (k: string): string => (q.같음[k] ? "✓" : "≠"); const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map( (k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`, ); const name = (g: string | string[]): string => [g].flat().join("+"); for (const x of q.엑셀.장비 ?? []) { const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래)); out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark(name(x.갈래))}`); } return out.join(" · "); } /** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */ export function newest(): (key: number) => () => boolean { const last = new Map(); return (key) => { const my = (last.get(key) ?? 0) + 1; last.set(key, my); return () => last.get(key) === my; }; } /** [맞음 · 별칭 모두 체크] 대상 줄 */ export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] => lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d)); 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 note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); const td = (text: string, cls = ""): HTMLElement => el("td", { text, className: cls, attrs: { title: text } }); const openSheet = (o: HopyoSheetOptions): void => void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); export interface CheckOptions { /** 실무자료 파일 층 */ scope: HopyoScope; key: string; no: number; /** 사전조합을 만들 층(없으면 파일 층) */ target?: HopyoScope; /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ canWrite: boolean; /** [← 호표 목록](본 칸) */ onBack?: () => void; /** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */ onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void; } /** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` }, }); const target = opt.target ?? opt.scope; const k = draftKey(opt.scope, opt.key, opt.no); let d = loadDraft(k); let got: HopyoOne | null = null; let built: HopyoBuilt | null = null; let seq = 0; let timer = 0; const ask = newest(); // 줄마다 최신 미리 셈만 const keep = (): void => saveDraft(k, d); async function load(quiet = false): Promise { const my = ++seq; const stop = quiet ? () => {} : showLoading(root); try { const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target); if (my !== seq) return; got = one; draw(); } catch (error) { if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load())); } finally { stop(); } } /** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */ function setParts(line: HopyoLine, parts: HopyoPart[]): void { d.대상[line.차례] = parts; d.체크[line.차례] = true; delete d.뺌[line.차례]; keep(); draw(); const mine = ask(line.차례); guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => { if (!mine()) return; if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상; if (g.품) d.품[line.차례] = g.품; keep(); draw(); }, fail); } function editOptions(line: HopyoLine, at: number): void { const parts = partsOf(line, d); const p = parts[at]; if (!p?.로직) return; fetchLogicInputs([p.로직]).then( (res) => openLaborInput({ title: `${p.로직} ${p.이름 ?? ""}`.trim(), logic: res.로직[0] ?? null, values: (p.입력값 ?? {}) as never, memo: null, readOnly: !opt.canWrite, done: (r) => setParts( line, parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)), ), }), fail, ); } /** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */ async function showSource(line: HopyoLine): Promise { const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean); const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : [])); const ours = codes.length ? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) => x.출처 ? [`${x.로직} — ${x.출처}`] : [], ) : []; openModal({ title: `원문 — ${line.이름}`, closeLabel: "닫기", wide: true, dialogClass: "m02-modal m02-hopyo__src", mount: (body) => { const part = (head: string, list: string[], cut: (s: string) => string): void => { body.append(el("h4", { text: head })); if (!list.length) body.append(note("없음")); for (const s of list) { const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf", }); view.show(cut(s)); body.append(note(s), view.root); } }; part("엑셀 인용 조항", excel, (s) => s); part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — ")); }, }); } function partCell(line: HopyoLine): HTMLElement { const parts = partsOf(line, d); return el("td", { className: "m02-hopyo__parts", children: parts.map((p, at) => { const times = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, }) as HTMLInputElement; times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; times.disabled = !opt.canWrite; times.addEventListener("change", () => { const v = times.value === "" ? null : Number(times.value); d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x)); keep(); draw(); }); const share = p.몫 != null ? ` · 몫 ${p.몫}` : ""; return el("div", { className: "m02-hopyo__part", children: [ el("span", { text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(), attrs: { title: p.어디 ?? "" }, }), times, ...(p.로직 ? [ createButton({ label: "옵션", variant: "ghost", size: "sm", onClick: () => editOptions(line, at), }), ] : []), ], }); }), }); } function lineRow(line: HopyoLine): HTMLElement { const m = line.맞춤; const parts = partsOf(line, d); const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } }); const input = box as HTMLInputElement; input.checked = isChecked(line, d) && checkable(line, d); input.disabled = !opt.canWrite || !checkable(line, d); input.addEventListener("change", () => { d.체크[line.차례] = input.checked; keep(); draw(); }); const out = el("select", { className: "ui-input ui-input--sm", attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" }, children: [ el("option", { text: "넣음", attrs: { value: "" } }), ...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })), ], }) as HTMLSelectElement; out.value = d.뺌[line.차례] ?? ""; out.disabled = !opt.canWrite || m.상태 === "%"; out.addEventListener("change", () => { if (out.value) d.뺌[line.차례] = out.value; else delete d.뺌[line.차례]; d.체크[line.차례] = !!out.value || d.체크[line.차례]; keep(); draw(); }); const sheet = (행: [number, number]): void => openSheet({ scope: opt.scope, key: opt.key, 파일: got?.파일 ?? "", canWrite: opt.canWrite, target, 단산행: 행, }); const acts = [ ...(opt.canWrite && m.상태 !== "%" ? [ createButton({ label: "고르기", variant: "ghost", size: "sm", onClick: () => openHopyoChoose({ line, openSheet: sheet, done: (ts) => setParts( line, ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), ), }), }), ] : []), createButton({ label: "원문 보기", variant: "ghost", size: "sm", onClick: () => void showSource(line), }), ...(line.근거 ? [ createButton({ label: "엑셀에서 보기", variant: "ghost", size: "sm", onClick: () => sheet(line.근거!.행), }), ] : []), out, ]; const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); const opts = parts .filter((p) => p.로직) .flatMap((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처).split(/, (?=[^,=]+=)| · /)) .filter(Boolean); if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`); const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; return el("tr", { className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`, attrs: { "data-line": String(line.차례), "data-state": m.상태 }, children: [ el("td", { children: [box] }), td(line.갈래), td(line.이름), td(line.규격), td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), td(line.단위), el("td", { attrs: { title: tags }, children: [ el("span", { className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, text: `${m.상태}${alias}`, }), ], }), partCell(line), td(opts.join("\n")), // 옵션 · 품 비교 = 항목마다 한 줄 td( compareText(d.품[line.차례] ?? m.품) .split(" · ") .join("\n"), ), el("td", { className: "m02-hopyo__acts", children: acts }), ], }); } async function make(h: HopyoOne["호표"]): Promise { const whole = !!h.통째 && (d.통째 ?? h.통째.체크); const lines = whole ? [] : bodyOf(h.줄, d); const out = leftOut(h.줄, d); const put = lines.filter((x) => x.대상).length; const outs = lines.length - put; const ask = whole ? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}` : `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` + (Object.keys(out).length ? `(${Object.entries(out) .map(([a, b]) => `${a} ${b}`) .join(" · ")})` : "") + " — 확인하고 사전조합을 만듦"; if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return; const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; showLoadingOverlay(); try { const res = await buildHopyo( target, from, lines, whole ? { 대상: h.통째!.대상.map(toTarget) } : null, ); dropDraft(k); d = empty(); built = res; const miss = res.빠진줄.length; showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success"); document.dispatchEvent(new Event(HOPYO_BUILT)); opt.onBuilt?.(res, h); await load(true); } catch (error) { fail(error); } finally { hideLoadingOverlay(); } } function builtBlock(res: HopyoBuilt): HTMLElement { const c = res.비교; const rows = res.빠진줄.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(x.까닭), ], }), ); return el("div", { className: "m02-hopyo__block m02-hopyo__built", attrs: { "data-built": res.참조.키 }, children: [ note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`), ...(rows.length ? [ el("h4", { text: `빠진 줄 ${rows.length}` }), el("table", { className: "ui-general-block__table m02-hopyo__table", children: [ el("thead", { children: [ el("tr", { children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) => el("th", { text: t }), ), }), ], }), el("tbody", { children: rows }), ], }), ] : []), note( `값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, ), ], }); } function draw(): void { if (!got) return; const h = got.호표; const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length; const calc = h.셈; window.clearTimeout(timer); if (calc && !calc.끝) timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS); const tools: HTMLElement[] = []; if (opt.canWrite) { const sure = sureLines(h.줄, d); const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" }); all.disabled = !sure.length; all.addEventListener("click", () => { for (const l of sure) d.체크[l.차례] = true; keep(); draw(); }); const whole = !!h.통째 && (d.통째 ?? h.통째.체크); const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" }); go.disabled = !whole && checked < 1; go.setAttribute("data-hopyo-make", ""); go.addEventListener("click", () => void make(h)); tools.push(all, go); } const back = opt.onBack ? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })] : []; const head = 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]} · 체크 ${checked}`), ...(calc && !calc.끝 ? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)] : []), ...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []), createButton({ label: "엑셀에서 보기", size: "sm", onClick: () => openSheet({ scope: opt.scope, key: opt.key, 파일: got!.파일, canWrite: opt.canWrite, target, 호표: opt.no, }), }), ...tools, ], }); const whole = h.통째 ? (() => { const box = el("input", { attrs: { type: "checkbox", "data-hopyo-whole": "" }, }) as HTMLInputElement; box.checked = d.통째 ?? h.통째.체크; box.disabled = !opt.canWrite; box.addEventListener("change", () => { d.통째 = box.checked; keep(); draw(); }); return [ el("label", { className: "m02-hopyo__bar m02-hopyo__whole", children: [ box, note( `통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`, ), ], }), ]; })() : []; const heads = [ "", "갈래", "품명", "규격", "수량", "단위", "상태", "우리 로직 · 배", "옵션", "품 비교", "", ]; root.replaceChildren( head, ...whole, h.줄.length ? 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 m02-hopyo__lines", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), el("tbody", { children: h.줄.map(lineRow) }), ], }), ], }) : note("구성 줄 없음"), ...(built ? [builtBlock(built)] : []), ); } void load(); return root; } /** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */ export function openHopyoCheck(opt: CheckOptions & { title?: string }): void { openModal({ title: opt.title ?? `확인 — 제${opt.no}호표`, closeLabel: "닫기", wide: true, dialogClass: "m02-modal m02-hopyo__check-modal", mount: (body, close) => body.append( buildHopyoCheck({ ...opt, onBuilt: (got, h) => { opt.onBuilt?.(got, h); close(); }, }), ), }); }