Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
542 lines
18 KiB
TypeScript
542 lines
18 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 { 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<EasyCard["percentBase"]>;
|
|
const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"];
|
|
const COSTS: CostKind[] = ["노무비", "재료비", "경비"];
|
|
|
|
interface Ctx {
|
|
doc: EasyDoc;
|
|
lines: Map<string, CalcLine>;
|
|
readOnly: boolean;
|
|
on: { change(doc: EasyDoc): void; trial?(): Record<string, unknown> };
|
|
/** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */
|
|
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 });
|
|
}
|
|
}
|
|
|
|
/** 표 · 조건 창 — 답(새 식)이 있으면 그 카드 식만 바꿈 */
|
|
async function editFormula(
|
|
ctx: Ctx,
|
|
card: EasyCard,
|
|
open: typeof openEasyTable | typeof openEasyCond,
|
|
): Promise<void> {
|
|
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<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);
|
|
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<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; trial?(): Record<string, unknown> },
|
|
): 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)]),
|
|
);
|
|
}
|