/* ============================================================================= * M01_EasyCalc_Cards.ts * 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추 * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시) * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { addCard, addPercent, checkCard, removeCard, updateCard, withInputs, type CostKind, type EasyCard, type EasyDoc, type PickResult, } from "./M01_EasyCalc_Model"; import { openEasyPick } from "./M01_EasyCalc_Pick"; import { openEasyCond, readCond } from "./M01_EasyCalc_Cond"; import { openEasyTable, readFinds } from "./M01_EasyCalc_Table"; import "./M01_EasyCalc_Cards.css"; const TEXT = { Group_person: ["사람", "Workers"], Group_material: ["재료", "Materials"], Group_machine: ["기계·경비", "Machines"], Group_logic: ["다른 일위대가", "Other unit prices"], Group_percent: ["덧붙임", "Add-ons"], Subtotal: ["소계", "Subtotal"], Won: ["원", "KRW"], Empty: ["아직 없음", "none yet"], Pending: ["계산 중", "calculating"], Tag_person: ["사람", "Worker"], Tag_material: ["재료", "Material"], Tag_machine: ["기계", "Machine"], Tag_logic: ["다른 일위대가", "Other unit price"], Tag_percent: ["덧붙임", "Add-on"], Pre_person: [" 이 ", " works "], Post_person: [" 일함", ""], Pre_pick: [" 를 ", " uses "], Post_material: [" 씀", ""], Post_machine: [" 씀", ""], Post_logic: ["", ""], Pre_percent: [" 의 ", " of "], Mid_percent: [" % 를 ", " % as "], Post_percent: [" 로 더함", ""], Slot: ["넣을 자리", "put into"], Pick_Empty: ["고르기", "choose"], Repick: ["다른 것으로 다시 고르기", "Choose another"], Eg_qty: ["예: 0.072", "e.g. 0.072"], Eg_percent: ["예: 2", "e.g. 2"], Eg_name: ["예: 공구손료", "e.g. tool wear"], Qty: ["수량", "Amount"], Name: ["이름", "Name"], Auto: ["자동", "auto"], Locked: ["잠김", "Locked"], ToNumber: ["숫자로 바꾸기", "Make it a number"], ByTable: ["표 보고 고르기", "Choose from table"], ByCond: ["조건 보고 고치기", "Edit conditions"], ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"], ToNumber_Ask: [ "자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음", "Freeze the auto value as a number", ], ToNumber_Ok: ["바꾸기", "Convert"], Remove: ["지움", "Remove"], Up: ["위로", "Move up"], Down: ["아래로", "Move down"], Add_person: ["+ 사람", "+ Worker"], Add_material: ["+ 재료", "+ Material"], Add_machine: ["+ 기계", "+ Machine"], Add_logic: ["+ 다른 일위대가", "+ Other unit price"], Add_percent: ["+ 덧붙임", "+ Add-on"], Base_노무비: ["사람 품값", "labor cost"], Base_재료비: ["재료값", "material cost"], Base_경비: ["기계·경비", "machine & expenses"], Base_사람줄합: ["사람 줄의 합", "sum of worker lines"], } as const; const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; type Group = "person" | "material" | "machine" | "logic" | "percent"; const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"]; type PercentBase = NonNullable; const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"]; const COSTS: CostKind[] = ["노무비", "재료비", "경비"]; interface Ctx { doc: EasyDoc; lines: Map; readOnly: boolean; on: { change(doc: EasyDoc): void; trial?(): Record }; /** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */ badText: Map; } const badTexts = new WeakMap>(); const closedGroups = new WeakMap>(); let picking = false; const groupOf = (card: EasyCard): Group => { if (card.kind !== "locked") return card.kind; if (card.source?.part === "덧줄") return "percent"; const kind = (card.raw as { 종류?: string } | null)?.종류; return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic"; }; /** Model 의 toRow 와 같은 나눔 — 줄 차례가 어긋나지 않게 */ const partOf = (card: EasyCard): "호표" | "덧줄" => card.kind === "percent" ? "덧줄" : card.kind === "locked" ? (card.source?.part ?? "덧줄") : "호표"; /** 식 수량이거나 종류를 못 알아본 줄 — 숫자로 바꾸기 전까지 잠김 */ const isLocked = (card: EasyCard): boolean => card.qtyFormula !== null || (card.kind === "locked" && card.lockedWhy !== null); /** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */ function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map { const out = new Map(); if (!ans || !ans.ok || !ans.lines) return out; const ordered = [ ...doc.cards.filter((c) => partOf(c) === "호표"), ...doc.cards.filter((c) => partOf(c) === "덧줄"), ]; if (ordered.length !== ans.lines.length) return out; ordered.forEach((card, i) => out.set(card.id, ans.lines![i])); return out; } const parseQty = (text: string): number | null => { const t = text.replace(/[,\s]/g, ""); if (t === "") return null; return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : Number.NaN; }; /** 검사가 막혀도 그리기는 계속 */ function warnOf(ctx: Ctx, card: EasyCard): string | null { if (ctx.doc.isBase) return null; try { return checkCard(card, ctx.doc); } catch { return null; } } function patchCard(ctx: Ctx, card: EasyCard, patch: Partial): void { ctx.on.change(updateCard(ctx.doc, card.id, patch)); } /** 화면 묶음 안 이웃과 자리 바꿈 — 잠금 카드도 같은 묶음이라 Model.moveCard 의 묶음과 달라 여기서 바꿈 */ function swapInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc { const same = doc.cards.filter((c) => groupOf(c) === groupOf(card)); const neighbor = same[same.indexOf(card) + step]; if (!neighbor) return doc; const cards = doc.cards.slice(); const a = cards.indexOf(card); const b = cards.indexOf(neighbor); [cards[a], cards[b]] = [cards[b], cards[a]]; return { ...doc, cards }; } async function repick(ctx: Ctx, card: EasyCard): Promise { if (picking || card.kind === "locked" || card.kind === "percent") return; picking = true; try { const pick = await openEasyPick(card.kind, { qty: card.qty }); if (!pick) return; const next = updateCard(ctx.doc, card.id, { name: pick.name, spec: pick.spec, unit: pick.unit, element: pick.element, qty: pick.qty, }); ctx.on.change(withInputs(next, pick.needInputs ?? [])); } finally { picking = false; } } async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise { if (picking) return; picking = true; try { const pick = await openEasyPick(kind); if (pick) ctx.on.change(addCard(ctx.doc, pick)); } finally { picking = false; } } function addBlankPercent(ctx: Ctx): void { ctx.on.change(addPercent(ctx.doc, "", "노무비", null, "경비")); } async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise { const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`; if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) { patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null }); } } /** 표 · 조건 창 — 답(새 식)이 있으면 그 카드 식만 바꿈 */ async function editFormula( ctx: Ctx, card: EasyCard, open: typeof openEasyTable | typeof openEasyCond, ): Promise { if (picking) return; picking = true; try { const formula = await open(card, ctx.doc, ctx.on.trial?.()); if (formula !== null) patchCard(ctx, card, { qtyFormula: formula }); } finally { picking = false; } } /* ------------------------------ 칸 만들기 ------------------------------ */ const text = (cls: string, value: string): HTMLSpanElement => el("span", { className: cls, text: value }); function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement { const bad = ctx.badText.get(card.id); const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty); const input = el("input", { className: "ui-input m01-easycards-qty", attrs: { type: "text", inputmode: "decimal", placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"), "aria-label": tc("Qty"), }, }); input.value = shown; if (ctx.readOnly) input.readOnly = true; input.addEventListener("change", () => { const qty = parseQty(input.value); if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value); else ctx.badText.delete(card.id); if (Object.is(qty, card.qty)) return; patchCard(ctx, card, { qty }); }); return input; } function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement { if (card.kind === "percent") { const input = el("input", { className: "ui-input m01-easycards-text", attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") }, }); input.value = card.name; if (ctx.readOnly) input.readOnly = true; input.addEventListener("change", () => { if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() }); }); return input; } const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty"); if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label); const button = el("button", { className: "m01-easycards-name m01-easycards-name--pick", text: `${label} ▾`, attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` }, }); button.addEventListener("click", () => void repick(ctx, card)); return button; } function selectBox( ctx: Ctx, values: T[], current: T | null, label: (v: T) => string, onPick: (v: T) => void, aria: string, ): HTMLElement { if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : ""); const select = el("select", { className: "ui-select m01-easycards-select", attrs: { "aria-label": aria }, }); if (current === null) select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") })); for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) })); select.value = current ?? ""; select.addEventListener("change", () => { if (select.value) onPick(select.value as T); }); return select; } function amountText(ctx: Ctx, card: EasyCard): HTMLElement { const line = ctx.lines.get(card.id); if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`); return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`); } function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null { if (ctx.readOnly) return null; const at = group.indexOf(card); const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => { const b = createButton({ label: glyph, variant: "ghost", size: "sm", disabled: off, onClick: () => ctx.on.change(swapInGroup(ctx.doc, card, step)), }); b.setAttribute("aria-label", label); return b; }; return el("div", { className: "m01-easycards-tools", children: [ move(tc("Up"), "▲", -1, at <= 0), move(tc("Down"), "▼", 1, at >= group.length - 1), createButton({ label: tc("Remove"), variant: "ghost", size: "sm", onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)), }), ], }); } /** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */ function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement { const g = groupOf(card); const locked = isLocked(card); const percent = card.kind === "percent"; const plain = card.kind === "locked"; const name = nameWidget(ctx, card); const value = locked ? lockedValue(ctx, card) : qtyInput(ctx, card); const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)]; if (percent) { parts.push( selectBox( ctx, BASES, card.percentBase, (v) => tc(`Base_${v}`), (v) => patchCard(ctx, card, { percentBase: v }), tc("Slot"), ), tc("Pre_percent"), value, tc("Mid_percent"), name, tc("Post_percent"), ); } else { parts.push( name, plain ? " " : g === "person" ? tc("Pre_person") : tc("Pre_pick"), value, text("m01-easycards-unit", ` ${card.unit}`), plain ? "" : tc(`Post_${g}` as "Post_person"), ); } const slot = percent ? el("span", { className: "m01-easycards-slot", children: [ ` · ${tc("Slot")} `, selectBox( ctx, COSTS, card.cost, (v) => tc(`Base_${v}`), (v) => patchCard(ctx, card, { cost: v }), tc("Slot"), ), ], }) : null; const head = el("div", { className: "m01-easycards-line", children: [ el("div", { className: "m01-easycards-sentence", children: slot ? [...parts, slot] : parts, }), amountText(ctx, card), toolButtons(ctx, card, group) ?? "", ], }); const rows: HTMLElement[] = [head]; const warn = warnOf(ctx, card); if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` })); if (locked) rows.push(lockedNote(ctx, card)); return el("li", { className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`, children: rows, }); } /** 잠금 카드 값 자리 — 「자동 0.50」 */ function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement { const line = ctx.lines.get(card.id); const value = line ? formatNumber(line.수량) : tc("Pending"); return text("m01-easycards-auto", `(${tc("Auto")} ${value})`); } function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement { const line = ctx.lines.get(card.id); const note: (HTMLElement | string)[] = [ text("m01-easycards-lock", `🔒 ${tc("Locked")}`), text("m01-easycards-why", card.lockedWhy ?? ""), ]; if (!ctx.readOnly) { const button = createButton({ label: tc("ToNumber"), variant: "ghost", size: "sm", disabled: !line, onClick: () => line && void lockToNumber(ctx, card, line.수량), }); if (!line) button.title = tc("ToNumber_Wait"); note.push(button); const formula = card.qtyFormula; const tools: [boolean, keyof typeof TEXT, typeof openEasyTable | typeof openEasyCond][] = [ [formula !== null && !!readFinds(formula)?.length, "ByTable", openEasyTable], [formula !== null && !!readCond(formula, ctx.doc.row), "ByCond", openEasyCond], ]; for (const [has, label, open] of tools) { if (has) note.push( createButton({ label: tc(label), variant: "ghost", size: "sm", onClick: () => void editFormula(ctx, card, open), }), ); } } return el("div", { className: "m01-easycards-locknote", children: note }); } /* ------------------------------ 묶음 · 전체 ------------------------------ */ function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement { const title = tc(`Group_${group}`); if (!cards.length) { return el("section", { className: "m01-easycards-group m01-easycards-group--empty", children: [ text("m01-easycards-group__title", title), text("m01-easycards-group__none", tc("Empty")), ], }); } const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l); const sum = found.length === cards.length ? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}` : `${tc("Subtotal")} ${tc("Pending")}`; const details = el("details", { className: "m01-easycards-group", children: [ el("summary", { className: "m01-easycards-group__head", children: [ text("m01-easycards-group__title", title), text( `m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`, sum, ), ], }), el("ul", { className: "m01-easycards-list", children: cards.map((c) => cardView(ctx, c, cards)), }), ], }); const closed = closedGroups.get(host) ?? new Set(); closedGroups.set(host, closed); details.open = !closed.has(group); details.addEventListener("toggle", () => { if (details.open) closed.delete(group); else closed.add(group); }); return details; } function addBar(ctx: Ctx): HTMLElement { const add = (key: keyof typeof TEXT, run: () => void) => createButton({ label: tc(key), variant: "ghost", onClick: run }); return el("div", { className: "m01-easycards-add", children: [ add("Add_person", () => void addByPick(ctx, "person")), add("Add_material", () => void addByPick(ctx, "material")), add("Add_machine", () => void addByPick(ctx, "machine")), add("Add_logic", () => void addByPick(ctx, "logic")), add("Add_percent", () => addBlankPercent(ctx)), ], }); } /** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */ export function renderCards( host: HTMLElement, doc: EasyDoc, ans: CalcAnswer | null, on: { change(doc: EasyDoc): void; trial?(): Record }, ): void { const badText = badTexts.get(host) ?? new Map(); badTexts.set(host, badText); const ctx: Ctx = { doc, lines: lineMap(doc, ans), readOnly: doc.isBase, on, badText }; host.classList.add("m01-easycards"); host.replaceChildren( ...GROUPS.map((g) => groupView( ctx, host, g, doc.cards.filter((c) => groupOf(c) === g), ), ), ...(ctx.readOnly ? [] : [addBar(ctx)]), ); }