/* ============================================================================= * M02_MasterTemplete_Hopyo_CheckRows.ts * [수정] 모달 줄 표(PLAN 786-2 · 요구 644 · 옛 359-3 꼴 되살림) — 구조물 조합 일위대가 상세 꼴 * (`Labor_Rows.moneyCell` · `PRICE_SLOTS` · `Labor_MatMarks` 빌려 씀 · 그 파일은 안 고침)에 새 판정 칸(785)을 얹음. * · 로직 표 = [확인] · 행 · 구분(엑셀 갈래 + 1차 판정) · 이름(고름 칩 · 단산 [블록 풀이] · 셈 못 함 까닭) · * 별칭(엑셀 품명) · 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배 · 단위 환산 칸) · 단위 · * 노무비 · 재료비 · 경비 · 합계(위 엑셀 단가 / 아래 우리 = 서버 미리 셈 ÷ 엑셀 수량) · 품 비교 · 단추 * · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추 * · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림 * · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산) * · 단산 줄(793-3b) = 판정 칩 「2차 필요」 대신 결과 칩(가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n · * SK 안 이었으면 블록 풀이 판정) · 단추 [가져온 식으로 열기](새 · `Hopyo_Imported` 모달) * 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표). * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { formatPrice } from "../common_util/common_util_money"; import type { JudgeRow } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { apartRow, bar, show, type RowKit } from "./M02_MasterTemplete_Hopyo_Rows"; import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc"; import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { rowChips } from "./logic/M02_Hopyo_Imported_Model"; import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>; /** 서버 미리 셈 한 줄(`/hopyo/{키}/price`) */ export interface PriceLine { 금액: Money4 | null; 자재?: CalcMaterial[]; 까닭: string[]; 칸: string[]; } /** 줄 금액 — 셈 중 = wait · 고른 것 없음 = null */ export type LinePrice = { wait: true } | { got: PriceLine } | { fail: string }; export interface CheckCtx { kit: RowKit; readOnly: boolean; price: (row: JudgeRow) => LinePrice | null; pick: (row: JudgeRow) => void; source: (row: JudgeRow) => void; options: (row: JudgeRow, p: DraftPick) => void; /** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */ out: (row: JudgeRow, value: string) => void; /** 단산 줄 [가져온 식으로 열기](793-3b) */ imported?: (row: JudgeRow) => void; } const COSTS = ["노무비", "재료비", "경비", "계"] as const; const COST_HEAD = { 노무비: "Prev_Labor", 재료비: "Prev_Material", 경비: "Prev_Expense", 계: "Prev_Sum", } as const; const MAT_KINDS = ["재료", "유가"]; export const NO_LOGIC = "로직없음"; const small = (text: string, cls = "m02-labrow__note"): HTMLElement => el("small", { className: cls, text, attrs: { title: text } }); /** 「새」 배지(785 새 기능 표지) */ export function newBadge(): HTMLElement { const b = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); b.dataset.checkNew = ""; b.title = L("M02_CheckNewHint"); return b; } /** 줄이 들어갈 표 */ export function tableOf(row: JudgeRow, picks: DraftPick[]): "logic" | "mat" { if (apartRow(row)) return "mat"; if (picks.length) return picks.every((p) => MAT_KINDS.includes(p.갈래)) ? "mat" : "logic"; return row.갈래.length > 0 && row.갈래.every((k) => MAT_KINDS.includes(k)) ? "mat" : "logic"; } /** 우리 금액 → 엑셀 1 단위(서버 반영합 ÷ 엑셀 수량 · 수량 없음 = 그대로) */ function perUnit(v: number | null | undefined, row: JudgeRow): number | null { if (v === null || v === undefined) return null; const q = row.수량.엑셀; return q ? v / q : v; } const priced = (p: LinePrice | null): PriceLine | null => (p && "got" in p ? p.got : null); const waiting = (p: LinePrice | null): boolean => !!p && "wait" in p; /** 이름 칸 덧글 — 셈 못 함 까닭 · 입력 필요 칸 */ function notes(p: LinePrice | null): HTMLElement[] { const got = priced(p); const why = [...(p && "fail" in p ? [p.fail] : []), ...(got?.까닭 ?? [])]; const out = why.map((t) => small(`${L("M02_CheckNoCalc")}: ${t}`, "ui-text ui-text--error")); if (got?.칸.length) out.push(small(`${L("M02_JudgeNeed")}: ${got.칸.join(" · ")}`, "ui-text ui-text--error")); // prettier-ignore return out; } /** 품 비교 — 고른 기초의 줄 대조 판정 수(단산 줄 · 블록 풀이) */ export function compareText(row: JudgeRow, picks: DraftPick[]): string { const keys = new Set(picks.map((p) => p.키)); const count = new Map(); for (const sec of row.풀이?.절 ?? []) for (const c of sec.후보) if (keys.has(c.키)) for (const x of c.줄) count.set(x.판정, (count.get(x.판정) ?? 0) + 1); return [...count].map(([k, n]) => `${k} ${n}`).join("\n"); } /** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배) + 단위 환산 칸 */ function qtyCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement { const 배 = ctx.kit.timesOf(row); const ex = row.수량.엑셀; const ours = ex === null || 배 === null ? "—" : show(ex * 배); const unit = ctx.kit.unitInput(row, picks); return el("td", { className: "m02-labrow__qty", attrs: { "data-label": tc("Labor_QtyCol") }, children: [ el("small", { className: "m02-labrow__base", text: ex === null ? "—" : `${show(ex)} ${row.단위 ?? ""}`.trim(), attrs: { title: L("M02_JudgeColExcel") } }), // prettier-ignore el("span", { className: "m02-labrow__applied", text: ours, attrs: { title: L("M02_JudgeColOurs") } }), // prettier-ignore ...(unit ? [unit] : []), ], }); } function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement { const ghost = (label: string, run: () => void, at: string): HTMLElement => { const b = createButton({ label, variant: "ghost", size: "sm", onClick: run }); b.dataset.checkAct = at; return b; }; const kids: HTMLElement[] = []; if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore if (row.번 !== undefined && ctx.imported) { const b = ghost(L("M02_CheckImported"), () => ctx.imported!(row), "imported"); (b as HTMLButtonElement).disabled = ctx.readOnly; kids.push(el("span", { className: "m02-judge__wrap", children: [b, newBadge()] })); } kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source")); for (const p of picks.filter((x) => x.갈래 === "기초")) { const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option"); b.title = `${p.키} ${L("M02_CheckOption")}`; kids.push(b); } if (!apartRow(row)) { const fixed = picks.length === 1 && picks[0].갈래 === "고정"; const out = createSelectField({ options: [ { value: "", text: L("M02_CheckAdd") }, { value: NO_LOGIC, text: L("M02_CheckNoLogic") }, ], value: fixed ? NO_LOGIC : "", compact: true, ariaLabel: L("M02_CheckNoLogic"), onChange: (v) => ctx.out(row, v), }); out.select.disabled = ctx.readOnly; out.select.dataset.checkOut = String(row.차례); out.root.title = L("M02_CheckNoLogicHint"); kids.push(out.root); } return el("td", { className: "m02-hopyo__acts", children: kids }); } const rowOf = ( row: JudgeRow, ctx: CheckCtx, picks: DraftPick[], cells: HTMLElement[], ): HTMLElement => // prettier-ignore el("tr", { className: "m02-labrow__row m02-hopyo__line", attrs: { "data-line": String(row.차례), "data-verdict": row.판정 }, children: [ el("td", { children: [ctx.kit.okChip(row, picks)] }), el("td", { className: "m02-labrow__no", children: [ctx.kit.rowJump(row)] }), ...cells, actsCell(row, ctx, picks), ], }); /** 단산 줄 결과 칩(793-3b) — SK 고른 것 표지 · 없으면 블록 풀이 판정 · 단산 아니면 판정 칩 */ function verdictOf(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { if (row.번 === undefined) return [ctx.kit.verdict(row)]; const got = rowChips(ctx.kit.picksOf(row)); const list = got ?? (row.풀이?.판정 ? [row.풀이.판정] : []); if (!list.length) return [ctx.kit.verdict(row)]; return list.map((text) => { const tone = text.startsWith("입력 필요") ? "danger" : text.includes("확정") || text.startsWith("가져온") ? "success" : text.includes("근접") ? "warning" : "info"; // prettier-ignore const b = createBadge(text, tone); b.dataset.judgeResult = text; return b; }); } const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement => el("td", { className: "m02-labrow__kind", attrs: { title: row.근거 }, children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ...verdictOf(row, ctx)])], }); function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { const picks = ctx.kit.picksOf(row); const p = ctx.price(row); const got = priced(p); const wait = waiting(p); const unit = picks[0]?.단위 || row.단위 || ""; const tr = rowOf(row, ctx, picks, [ kindCell(row, ctx), el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), ...notes(p)] }), // prettier-ignore el("td", { className: "m02-labrow__alias", attrs: { title: `${row.이름 ?? ""} ${row.규격 ?? ""}`.trim() }, children: [row.이름 ?? "", ...(row.규격 ? [small(row.규격)] : [])], }), qtyCell(row, ctx, picks), el("td", { className: "m02-labrow__unit", text: unit }), ...COSTS.map( (c) => moneyCell(row.단가?.[c] ?? null, got ? perUnit(got.금액?.[c], row) : null, wait, tc(COST_HEAD[c])), // prettier-ignore ), el("td", { className: "m02-hopyo__compare", text: compareText(row, picks) }), ]); const body = ctx.kit.solved(row); if (!body) return [tr]; const wide = el("td", { children: [body], attrs: { colspan: "15" } }); return [tr, el("tr", { className: "m02-hopyo__solved", children: [wide] })]; } function matRow(row: JudgeRow, ctx: CheckCtx): HTMLElement { const picks = ctx.kit.picksOf(row); const p = ctx.price(row); const got = priced(p); const wait = waiting(p); const mats = got?.자재 ?? []; const first = mats[0]; const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; const cell = (v: number | null | undefined): string => wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v); const pk = picks[0]; const apart = apartRow(row) || !!pk?.별도계상; return rowOf(row, ctx, picks, [ el("td", { className: "m02-labrow__code", text: picks.map((x) => x.키 ?? "").filter(Boolean).join(" · ") || "—" }), // prettier-ignore el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), small(`${L("M02_JudgeColExcel")} ${row.이름 ?? ""}`.trim()), ...notes(p)], // prettier-ignore }), el("td", { className: "m02-labrow__spec", text: pk?.규격 || row.규격 || "", attrs: { title: pk?.규격 || row.규격 || "" } }), // prettier-ignore qtyCell(row, ctx, picks), el("td", { className: "m02-labrow__unit", text: pk?.단위 || row.단위 || "" }), ...markCells({ 별도계상: apart ? "별도계상" : "직접계산" }), ...PRICE_SLOTS.map((slot) => el("td", { className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, text: cell(first?.가격?.[slot]), attrs: { "data-label": slot, "data-price-slot": slot }, }), ), moneyCell(row.단가?.계 ?? null, got ? perUnit(ours, row) : null, wait, tc("Labor_AppliedSum")), ]); } /** 머리 칸 — 글 · 새 기능이면 「새」 배지 */ const head = (text: string, fresh = false): HTMLElement => el("th", { children: fresh ? [text, " ", newBadge()] : [text] }); const grid = (heads: HTMLElement[], rows: HTMLElement[], kind: string): HTMLElement => el("div", { className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines", attrs: { "data-hopyo-table": kind }, children: [ el("thead", { children: [el("tr", { children: heads })] }), el("tbody", { children: rows }), ], }), ], }); /** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */ export function checkTables(rows: JudgeRow[], ctx: CheckCtx): HTMLElement[] { const logic = rows.filter((r) => tableOf(r, ctx.kit.picksOf(r)) === "logic"); const mat = rows.filter((r) => !logic.includes(r)); const first = (): HTMLElement[] => [head(L("M02_JudgeColOk"), true), head(L("M02_JudgeColRow"))]; // 표마다 새 노드 const out: HTMLElement[] = []; if (logic.length) out.push( grid( [ ...first(), head(`${tc("Sub")} · ${L("M02_JudgeColVerdict")}`, true), head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true), head(tc("Col_Alias")), head(tc("Labor_QtyCol"), true), head(tc("Col_Unit")), ...COSTS.map((c) => head(tc(COST_HEAD[c]))), head(L("M02_CheckCompare")), head(""), ], logic.flatMap((r) => logicRow(r, ctx)), "logic", ), ); if (mat.length) out.push( el("h4", { className: "m02-hopyo__subhead", text: L("M02_CheckMat") }), grid( [ ...first(), head(tc("Labor_Code")), head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true), head(tc("Labor_Spec")), head(tc("Labor_QtyCol"), true), head(tc("Labor_Unit")), ...markHeads().map((t) => head(t)), ...PRICE_SLOTS.map((t) => head(t)), head(`${L("M02_JudgeColExcel")} / ${L("M02_JudgeColOurs")} ${tc("Labor_AppliedSum")}`), head(""), ], mat.map((r) => matRow(r, ctx)), "mat", ), ); return out; }