feat(M01-쉬운단가): 카드 그리기 Cards 추가 - 묶음·소계·문장 칸·잠금 카드·지움·▲▼·경고·더하기
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
.m01-easycards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-easycards-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-easycards-group__head,
|
||||
.m01-easycards-group--empty {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 44px;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycards-group__head {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycards-group--empty {
|
||||
border-style: dashed;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-easycards-group__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-easycards-group__sum {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-easycards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m01-easycards-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-easycards-card--locked {
|
||||
border-left: 4px dashed var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-easycards-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycards-sentence {
|
||||
display: flex;
|
||||
flex: 1 1 24em;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycards-tag {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-easycards-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-easycards-name--pick {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycards-name--pick:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-easycards .ui-input,
|
||||
.m01-easycards .ui-select {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycards-qty {
|
||||
flex: none;
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.m01-easycards-text {
|
||||
flex: none;
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
.m01-easycards-select {
|
||||
flex: none;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.m01-easycards-auto,
|
||||
.m01-easycards-why {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-easycards-amount {
|
||||
margin-left: auto;
|
||||
font-weight: var(--font-weight-bold);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easycards-amount--wait {
|
||||
opacity: 0.5;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.m01-easycards-tools {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easycards-tools .ui-btn,
|
||||
.m01-easycards-locknote .ui-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycards-warn {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-easycards-locknote {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easycards-lock {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m01-easycards-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-easycards-add .ui-btn {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.m01-easycards-amount {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.m01-easycards-tools {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/* =============================================================================
|
||||
* 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,
|
||||
checkCard,
|
||||
moveCard,
|
||||
removeCard,
|
||||
updateCard,
|
||||
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 blankSeq = 0;
|
||||
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";
|
||||
};
|
||||
|
||||
const partOf = (card: EasyCard): "호표" | "덧줄" =>
|
||||
card.source?.part ?? (card.kind === "percent" ? "덧줄" : "호표");
|
||||
|
||||
/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */
|
||||
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));
|
||||
}
|
||||
|
||||
/** 같은 묶음 안 이웃을 지나갈 때까지 한 칸씩 — 묶음 밖 카드가 사이에 있어도 눈에 보이게 옮겨짐 */
|
||||
function moveInGroup(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 at = (d: EasyDoc, id: string): number => d.cards.findIndex((c) => c.id === id);
|
||||
let next = doc;
|
||||
for (let n = doc.cards.length; n > 0; n--) {
|
||||
if ((at(next, card.id) - at(next, neighbor.id)) * step >= 0) break;
|
||||
const moved = moveCard(next, card.id, step);
|
||||
if (moved === next) break;
|
||||
next = moved;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
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;
|
||||
let next = updateCard(ctx.doc, card.id, {
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
element: pick.element,
|
||||
});
|
||||
const have = new Set(next.inputs.map((i) => i.이름));
|
||||
const extra = (pick.needInputs ?? []).filter((i) => !have.has(i.이름));
|
||||
if (extra.length) next = { ...next, inputs: [...next.inputs, ...extra] };
|
||||
ctx.on.change(next);
|
||||
} 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 {
|
||||
const card: EasyCard = {
|
||||
id: `blank-${++blankSeq}`,
|
||||
kind: "percent",
|
||||
name: "",
|
||||
spec: "",
|
||||
unit: "%",
|
||||
qty: null,
|
||||
qtyFormula: null,
|
||||
cost: "경비",
|
||||
percentBase: "노무비",
|
||||
element: null,
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
ctx.on.change({ ...ctx.doc, cards: [...ctx.doc.cards, card] });
|
||||
}
|
||||
|
||||
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(moveInGroup(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 = card.kind === "locked";
|
||||
const name = nameWidget(ctx, card);
|
||||
const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)];
|
||||
|
||||
if (g === "percent") {
|
||||
const base = card.percentBase;
|
||||
parts.push(
|
||||
selectBox(
|
||||
ctx,
|
||||
BASES,
|
||||
locked ? null : base,
|
||||
(v) => tc(`Base_${v}`),
|
||||
(v) => patchCard(ctx, card, { percentBase: v }),
|
||||
tc("Slot"),
|
||||
),
|
||||
tc("Pre_percent"),
|
||||
locked ? lockedValue(ctx, card) : qtyInput(ctx, card),
|
||||
locked ? "" : tc("Mid_percent"),
|
||||
name,
|
||||
tc("Post_percent"),
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
name,
|
||||
g === "person" ? tc("Pre_person") : tc("Pre_pick"),
|
||||
locked ? lockedValue(ctx, card) : qtyInput(ctx, card),
|
||||
text("m01-easycards-unit", ` ${card.unit}`),
|
||||
tc(`Post_${g}` as "Post_person"),
|
||||
);
|
||||
}
|
||||
|
||||
const slot =
|
||||
g === "percent" && !locked
|
||||
? 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)]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user