Files
Aislo/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts
T

506 lines
17 KiB
TypeScript

/* =============================================================================
* 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 "./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"],
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<EasyCard["percentBase"]>;
const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"];
const COSTS: CostKind[] = ["노무비", "재료비", "경비"];
interface Ctx {
doc: EasyDoc;
lines: Map<string, CalcLine>;
readOnly: boolean;
on: { change(doc: EasyDoc): void };
/** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */
badText: Map<string, string>;
}
const badTexts = new WeakMap<HTMLElement, Map<string, string>>();
const closedGroups = new WeakMap<HTMLElement, Set<Group>>();
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<string, CalcLine> {
const out = new Map<string, CalcLine>();
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<EasyCard>): 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<void> {
if (picking || card.kind === "locked" || card.kind === "percent") return;
picking = true;
try {
const pick = await openEasyPick(card.kind);
if (!pick) return;
const next = updateCard(ctx.doc, card.id, {
name: pick.name,
spec: pick.spec,
unit: pick.unit,
element: pick.element,
});
ctx.on.change(withInputs(next, pick.needInputs ?? []));
} finally {
picking = false;
}
}
async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise<void> {
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<void> {
const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`;
if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) {
patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null });
}
}
/* ------------------------------ 칸 만들기 ------------------------------ */
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<T extends string>(
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);
}
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<Group>();
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 },
): void {
const badText = badTexts.get(host) ?? new Map<string, string>();
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)]),
);
}