/* ============================================================================= * M02_MasterTemplete_Hopyo_Rows.ts * 엑셀 불러오기 줄 칸 부품 · 옮기기(PLAN 785-6 · 786-2 · 요구 641 · 642 · 644 · 설계 642-A 2-3 · 2-4 · 2-6). * · `createRowKit` = 호표 하나의 줄 칸 부품 — [수정] 모달 「확인 — 제N호표」(`Hopyo_Check` · `Hopyo_CheckRows`)가 * 옛 확인 창 표 칸 안에 담음: 1차 판정 칩 · 후보 복수 고름 칩(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · * 기계 · 율 · 고정) · 단산 줄 [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸 · 출처 꼬리표) + * 「단가산출 통째」 칩(단산N → SK) · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [확인] * · `createJudgeMove` = 오른쪽 판 아래 [옮기기 (n)] · `runMove` = 옮기기 한 번(모달 [사전조합 생성]도 이 길 · 이 호표만) * · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음 * · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈 * (785-6 시트 아래 줄 판 몸은 786-2 에서 모달로 옮김) * ========================================================================== */ import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { askConfirm, createButton, createInputField, el, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { formatNumber } from "../common_util/common_util_money"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block"; import { checkJudge, moveJudge, type JudgeRow, type MoveBody, type MoveBlocked, type MoveResult, } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { apartPick, bare, 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", }; export const show = (v: unknown): string => v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v); export const bar = (kids: (HTMLElement | string)[]): HTMLElement => el("div", { className: "ui-toolbar m02-judge__wrap", children: kids }); export type SheetRole = "일위대가표" | "단가산출상세"; export interface RowKitOptions { /** 파일 층 */ file: HopyoScope; key: string; state: JudgeState; no: number; /** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */ onSheet?: (행: number, role: SheetRole) => void; readOnly?: boolean; /** [블록 풀이] 펼침 · 접음 — 부르는 쪽이 다시 그림 */ redraw: () => void; } /** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */ const wholeSk = (row: JudgeRow): DraftPick => ({ 갈래: "단가산출", 키: `단산${row.번}`, 이름: row.이름 ?? `단산${row.번}`, 규격: row.규격 ?? "", 단위: row.단위 ?? "", 입력값: {}, 출처: {}, 몫: null, }); /** 별도계상 줄(786-1 · 서버 제안 = 고정 줄 하나 `apartPick`) — 고름 칩 없이 글만 */ export const apartRow = (row: JudgeRow): boolean => row.후보.length === 1 && apartPick(row.후보[0]); /** 호표 하나 줄 칸 부품 — 판정 칩 · 고름 칩 · 수량(단위 환산) · [확인] · 블록 풀이 몸 */ export function createRowKit(opt: RowKitOptions) { const no = opt.no; const open = new Set(); const bodies = new Map>(); 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 verdict(row: JudgeRow): HTMLElement { const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral"); v.dataset.judgeVerdict = row.판정; return v; } function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement { const kids: HTMLElement[] = []; if (apartRow(row)) return bar([createText(L("M02_CheckApart"), "muted")]); if (row.번 !== undefined && row.풀이) { const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); opt.redraw(); } }); // 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.키); if (missing(p).length) b.title = `${L("M02_JudgeNeed")} ${missing(p).join(" · ")}`; 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.규격 ? ` ${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); const need = picks.find((p) => samePick(p, c)); if (need && missing(need).length) { const b = createBadge(`${L("M02_JudgeNeed")} ${missing(need).length}`, "danger"); b.dataset.judgeNeed = missing(need).join(","); kids.push(b); } } if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning")); } return bar(kids); } /** 단위 환산 칸 — 같은 단위면 null */ function unitInput(row: JudgeRow, picks: DraftPick[]): HTMLElement | null { const line = lineOf(opt.state.get(), no, row.차례); const 배 = line?.배 ?? row.수량.배; const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore if (view.mode !== "input" || opt.readOnly) return null; 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([L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]); } /** 지금 배(사용자 단위 환산 › 서버) */ const timesOf = (row: JudgeRow): number | null => lineOf(opt.state.get(), no, row.차례)?.배 ?? row.수량.배; function okChip(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 rowJump(row: JudgeRow): HTMLElement | string { if (row.행 === null || !opt.onSheet) return show(row.행); const b = createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }); // prettier-ignore b.dataset.judgeRow = String(row.행); return b; } /** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */ const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출"; const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p)); /** 펼친 단산 줄의 블록 풀이 몸(접힘 = null) */ function solved(row: JudgeRow): HTMLElement | null { if (!row.풀이 || !open.has(row.차례)) return null; 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; } return { picksOf, edit, verdict, chips, unitInput, timesOf, okChip, rowJump, solved }; } export type RowKit = ReturnType; export interface JudgeMoveOptions { file: HopyoScope; key: string; target: HopyoScope; state: JudgeState; readOnly?: boolean; onMoved?: (r: MoveResult) => void; } /** 옮기기 한 번 — 409 = 물음 뒤 덮음 · 옮긴 것은 캐시에서 걷음 · 알림 · 실패 = null */ export async function runMove( opt: Pick, body: MoveBody, 막힘: MoveBlocked[], ): Promise { try { const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => { // 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만) if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e; return moveJudge(opt.target, opt.key, { ...body, 덮음: true }); }); 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"); return r; } catch (e) { showToast((e as Error).message, "error"); return null; } } /** 오른쪽 판 아래 [옮기기 (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; const r = await runMove(opt, body, 막힘); count(); if (r) opt.onMoved?.(r); } const off = opt.state.on(count); count(); return { root, destroy(): void { off(); root.remove(); }, }; }