/* ============================================================================= * M02_MasterTemplete_Hopyo_WizardSk.ts * 일위대가 불러오기 마법사 ② 단가산출 칸(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 모두 줄로만 · 자동 없음. * · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 이름 · 판정(「옮길 수 있음」 · * 「고를 줄 n」 — 캐시 고름 겹침) · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표(가까운 기초 이름 · 참고) · 만든 SK · * [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄] 펼침 = 줄 표(`Hopyo_SkLines`) · [보기] = 엑셀 보기 그 블록 첫 행 * · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음 * · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) · * 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음 * · `count()` = 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄(마법사 [끝내기] 글) * · 780-2(요구 627) — 부름 칸(「일위대가 n 곳」 · 「일위대가 밖」) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · * 좁은 꼴(`narrow` = 엑셀 보기 오른쪽 · 칸 여섯 · 블록 줄 누름 = `onRow` · `mark(번)` 강조) · `readOnly` = 옮기기 숨김 · * 두 벌 맞춤 = 알림 `SK_SYNC`(같은 캐시 열쇠 · 고름 바뀜 → 다시 그림 · 옮김 → 받은 판정 그대로) · 옮김 뒤 `HOPYO_BUILT` * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createBanner } from "@ui/ui_template_banner"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { createSegment } from "@ui/ui_template_segment"; import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; import { formatPrice } from "../common_util/common_util_money"; import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi"; import { skLineTable } from "./M02_MasterTemplete_Hopyo_SkLines"; import { GOOD, canConfirm, filterBlocks, outside, picksOf, readDraft, setPick, setSure, sureBody, tidy, verdictOf, waiting, type SkBlock, type SkDraft, type SkFilter, type SkJudged, } from "./logic/M02_MasterTemplete_Hopyo_SkPick"; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => v === null || v === undefined ? "—" : formatPrice(v); export const skDraftKey = (scope: HopyoScope, key: string): string => `m02-hopyo-sk:${scope.layer}:${scope.owner ?? ""}:${key}`; export interface SkStep { root: HTMLElement; /** 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄 — 판정 전 null */ count: () => { 블록: number; 밖: number; 줄: number } | null; /** 그 블록 펼치고 화면에 보임(안 이은 줄 창 [단가산출 걸음에서 확인]) */ show: (번: number) => void; /** 그 블록 줄 강조 · 보이게(펼치지 않음 · 거른 줄이면 거르기 풂) — null = 강조 지움 */ mark: (번: number | null) => void; } export interface SkStepOptions { /** 좁은 꼴(엑셀 보기 오른쪽) — 칸 여섯 · 금액은 줄 title */ narrow?: boolean; /** 블록 줄 누름(단추 밖) */ onRow?: (번: number) => void; /** 옮기기 숨김(만들 층 ≠ 파일 층 · 쓰기 권한 없음) */ readOnly?: boolean; } /** 두 벌(마법사 칸 · 엑셀 보기 오른쪽) 맞춤 알림 — detail `{dk, from, got?}` */ export const SK_SYNC = "m02-hopyo-sk"; type SyncDetail = { dk: string; from: object; got?: SkJudged }; /** ② 단가산출 칸 — `파일` = 엑셀 보기 창 머리 · `onChange` = 판정 · 옮김 뒤(끝내기 글) */ export function sangeunStep( scope: HopyoScope, key: string, 파일: string, onChange?: () => void, o: SkStepOptions = {}, ): SkStep { const dk = skDraftKey(scope, key); const me = {}; const load = (): SkDraft => readDraft(sessionStorage.getItem(dk)); const save = (d: SkDraft): void => sessionStorage.setItem(dk, JSON.stringify(d)); const tell = (more: Partial = {}): void => void document.dispatchEvent(new CustomEvent(SK_SYNC, { detail: { dk, from: me, ...more } })); // prettier-ignore let got: SkJudged | null = null; let fails: SkJudged["못만듦"] = []; let current: number | null = null; // 강조 블록 let filter: SkFilter = "all"; const open = new Set(); // 펼친 블록 const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } }); const seg = createSegment({ options: [ { value: "all", label: L("M02_SkFilterAll") }, { value: "in", label: L("M02_SkFilterIn") }, { value: "out", label: L("M02_SkFilterOut") }, { value: "open", label: L("M02_SkFilterOpen") }, ], value: filter, size: 24, ariaLabel: L("M02_SkFilter"), onChange: (v) => { filter = v as SkFilter; draw(); }, }); seg.root.dataset.skFilter = ""; const again = createButton({ label: L("M02_SkJudge"), variant: "ghost", size: 28, onClick: () => void judge() }); // prettier-ignore again.dataset.skJudge = ""; const move = createButton({ label: L("M02_SkMove").replace("{n}", "0"), variant: "filled", size: 28, disabled: true, onClick: () => void run() }); // prettier-ignore move.title = L("M02_SkMoveTip"); move.dataset.skMove = ""; const root = el("div", { className: "m02-hopyo", children: [ ...(o.narrow ? [] : [createText(L("M02_SkHint"), "hint", 14)]), seg.root, out, el("div", { className: "ui-toolbar", children: o.readOnly ? [again] : [move, again] }), ], }); /** 다른 벌이 고름 · 옮김을 알림 — 떨어진 벌은 듣기 그침 */ const onSync = (e: Event): void => { const x = (e as CustomEvent).detail; if (!root.isConnected) return document.removeEventListener(SK_SYNC, onSync); if (x.dk !== dk || x.from === me || !got) return; if (x.got) { got = x.got; fails = []; } draw(); }; document.addEventListener(SK_SYNC, onSync); const sheet = (행: number): void => void import("./M02_MasterTemplete_Hopyo_Sheet") .then((m) => m.openHopyoSheet({ scope, key, 파일, canWrite: true, target: scope, 단산행: 행 }), ) .catch((error: unknown) => showToast(why(error), "error")); async function judge(): Promise { again.disabled = true; out.replaceChildren(createText(L("M02_SkJudging"), "muted", 14)); try { got = await judgeSangeun(scope, key); fails = []; draw(); } catch (error) { out.replaceChildren(createBanner({ tone: "warning", text: why(error) })); } finally { again.disabled = false; onChange?.(); } } async function run(): Promise { if (!got) return; const body = sureBody(load(), got.판정); if (!body.length) return showToast(L("M02_SkSureFirst"), "warning"); move.disabled = true; try { const next = await judgeSangeun(scope, key, body); const made = new Set(next.만듦.map((x) => x.번)); const d = load(); save({ ...d, 확인: d.확인.filter((n) => !made.has(n)) }); got = next; fails = next.못만듦; tell({ got: next }); if (made.size) document.dispatchEvent(new Event(HOPYO_BUILT)); // 열린 엑셀 보기 칠 · 좌측 나무 다시 showToast( L("M02_SkMoved") .replace("{made}", String(made.size)) .replace("{bad}", String(fails.length)), fails.length ? "warning" : "success", ); draw(); } catch (error) { showToast(why(error), "error"); draw(); } finally { onChange?.(); } } const cols: Column[] = o.narrow ? [ { label: L("M02_SkColBlock") }, { label: L("M02_SkColCall"), width: "92px" }, { label: L("M02_SkColVerdict"), width: "92px" }, { label: L("M02_SkColMade"), width: "92px" }, { label: L("M02_SkSure"), width: "64px" }, { label: "", width: "60px" }, ] : [ { label: L("M02_SkColBlock"), width: "72px" }, { label: L("M02_SangeunColName") }, { label: L("M02_SangeunColUnit"), width: "56px" }, { label: L("M02_SkColCall"), width: "104px" }, { label: L("M02_SkColVerdict") }, { label: L("M02_SkColOurs"), align: "end" }, { label: L("M02_SkColExcel"), align: "end" }, { label: L("M02_SkColBasis") }, { label: L("M02_SkColMade"), width: "110px" }, { label: L("M02_SkSure"), width: "72px" }, { label: "", width: "96px" }, ]; /** 부름 칸 — 「일위대가 n 곳」(title 호표 번) · 「일위대가 밖」 · 옛 서버(칸 없음) = — */ function callCell(b: SkBlock): HTMLElement | string { if (!b.부름) return "—"; const out = outside(b); const word = out ? L("M02_SkCallOut") : L("M02_SkCallIn").replace("{n}", String(b.부름.length)); // prettier-ignore const badge = createBadge(word, out ? "neutral" : "info"); badge.title = b.부름.map((n) => `제${n}호표`).join(" · "); badge.dataset.skCall = out ? "out" : "in"; return badge; } /** 블록 한 줄 + 펼친 줄 표 */ function blockRows(b: SkBlock, d: SkDraft): HTMLTableRowElement[] { const picks = picksOf(d, b.번); const word = verdictOf(b, picks); const verdict = createBadge(word, word === GOOD ? "success" : "info"); verdict.dataset.skVerdict = String(b.번); const basis = b.근거 ? createBadge(`${L("M02_SkBasisTag")} ${b.근거.이름 ?? b.근거.로직}`, "neutral") : "—"; // prettier-ignore if (basis instanceof HTMLElement) basis.title = `${b.근거!.로직} · ${b.근거!.판정 ?? ""}`; const made = b.만든 ? createBadge(b.만든, "success") : "—"; const ok = canConfirm(b, picks); const sure = createToggle({ label: L("M02_SkSure"), kind: "chip", size: 24, checked: d.확인.includes(b.번), disabled: !ok, onChange: (on) => { save(setSure(load(), b, on)); draw(); tell(); }, }); sure.root.dataset.skSure = String(b.번); sure.root.title = ok ? L("M02_SkSureTip") : L(b.줄.length ? "M02_SkSureOff" : "M02_SkSureEmpty"); const lines = createButton({ label: `${L("M02_SkOpen")} ${b.줄.length}`, size: 24, variant: open.has(b.번) ? "filled" : "ghost", onClick: () => { if (open.has(b.번)) open.delete(b.번); else open.add(b.번); draw(); }, }); lines.dataset.skOpen = String(b.번); const first = b.줄.find((l) => l.행 !== null)?.행 ?? null; const view = first !== null && !o.narrow ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => sheet(first) }) : ""; // prettier-ignore const name = `단산${b.번} ${b.이름 ?? ""}${b.단위 ? ` [${b.단위}]` : ""}`; const head = tableRow( cols, o.narrow ? [name, callCell(b), verdict, made, sure.root, lines] : [ `단산${b.번}`, b.이름 ?? "", b.단위 ?? "", callCell(b), verdict, won(b.우리금액), won(b.엑셀금액), basis, made, sure.root, el("div", { className: "ui-toolbar", children: [lines, view] }), ], ); head.dataset.skBlock = String(b.번); if (b.번 === current) head.classList.add("is-current"); if (o.narrow) { head.title = `${name} · ${L("M02_SkColOurs")} ${won(b.우리금액)} · ${L("M02_SkColExcel")} ${won(b.엑셀금액)}`; // prettier-ignore head.addEventListener("click", (e) => { if (!(e.target as Element).closest("button, label, input")) o.onRow?.(b.번); }); } if (!open.has(b.번)) return [head]; const table = skLineTable({ scope, block: b, picks, onPick: (at, p) => { save(tidy(setPick(load(), b.번, at, p), got!.판정)); draw(); tell(); }, onSheet: sheet, }); const sub = tableRow(cols, [{ content: table, colSpan: cols.length }]); sub.className = "is-sub"; return [head, sub]; } function draw(): void { if (!got) return; const d = tidy(load(), got.판정); save(d); const blocks = got.판정; const good = blocks.filter((b) => canConfirm(b, picksOf(d, b.번))).length; const made = blocks.filter((b) => b.만든).length; const sum = L("M02_SkSummary") .replace("{b}", String(blocks.length)) .replace("{ok}", String(good)) .replace("{wait}", String(blocks.length - good)) .replace("{made}", String(made)) .replace("{sure}", String(d.확인.length)); const { root: table, tbody } = tableShell(cols, { density: "data" }); const shown = filterBlocks(blocks, d, filter); for (const b of shown) tbody.append(...blockRows(b, d)); const band = fails.length ? [ createBanner({ tone: "warning", text: L("M02_SkFailed").replace("{n}", String(fails.length)), details: fails.map((x) => `단산${x.번} ${x.이름 ?? ""} — ${x.까닭}`).join("\n"), }), ] : []; const far = blocks.filter(outside).length; const where = blocks.some((b) => b.부름) ? ` · ${L("M02_SkSummaryIn").replace("{in}", String(blocks.length - far)).replace("{out}", String(far))}` : ""; // prettier-ignore out.replaceChildren( createText(sum + where, "muted", 14), ...band, shown.length ? table : createText(L("M02_SkNone"), "muted", 14), ); move.disabled = !d.확인.length; move.querySelector(".ui-btn__label")!.textContent = L("M02_SkMove").replace( "{n}", String(d.확인.length), ); onChange?.(); } void judge(); return { root, count: () => (got ? waiting(load(), got.판정) : null), show: (번) => { open.add(번); draw(); out.querySelector(`[data-sk-block="${번}"]`)?.scrollIntoView({ block: "center" }); }, mark: (번) => { out.querySelector("tr.is-current")?.classList.remove("is-current"); current = 번; if (번 === null || !got) return; if (!out.querySelector(`[data-sk-block="${번}"]`) && filter !== "all") { filter = "all"; seg.set("all"); draw(); } const row = out.querySelector(`[data-sk-block="${번}"]`); row?.classList.add("is-current"); row?.scrollIntoView({ block: "nearest" }); }, }; }