feat(M01-쉬운단가): Cards 를 Model·Pick 실물에 맞춤 - 잠금 판정 · 덧붙임 더하기 · 묶음 안 ▲▼ · PLAN Cards 검증완료

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 09:49:52 +09:00
co-authored by Claude Sonnet 5.5
parent a0882977bd
commit 571eeafddc
+50 -62
View File
@@ -12,10 +12,11 @@ import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import {
addCard,
addPercent,
checkCard,
moveCard,
removeCard,
updateCard,
withInputs,
type CostKind,
type EasyCard,
type EasyDoc,
@@ -98,7 +99,6 @@ interface Ctx {
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 => {
@@ -108,8 +108,17 @@ const groupOf = (card: EasyCard): Group => {
return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic";
};
/** Model 의 toRow 와 같은 나눔 — 줄 차례가 어긋나지 않게 */
const partOf = (card: EasyCard): "호표" | "덧줄" =>
card.source?.part ?? (card.kind === "percent" ? "덧줄" : "호표");
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> {
@@ -144,20 +153,16 @@ 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 {
/** 화면 묶음 안 이웃과 자리 바꿈 — 잠금 카드도 같은 묶음이라 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 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;
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> {
@@ -166,16 +171,13 @@ async function repick(ctx: Ctx, card: EasyCard): Promise<void> {
try {
const pick = await openEasyPick(card.kind);
if (!pick) return;
let next = updateCard(ctx.doc, card.id, {
const 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);
ctx.on.change(withInputs(next, pick.needInputs ?? []));
} finally {
picking = false;
}
@@ -193,22 +195,7 @@ async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise<void> {
}
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] });
ctx.on.change(addPercent(ctx.doc, "", "노무비", null, "경비"));
}
async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise<void> {
@@ -309,7 +296,7 @@ function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement |
variant: "ghost",
size: "sm",
disabled: off,
onClick: () => ctx.on.change(moveInGroup(ctx.doc, card, step)),
onClick: () => ctx.on.change(swapInGroup(ctx.doc, card, step)),
});
b.setAttribute("aria-label", label);
return b;
@@ -332,54 +319,55 @@ function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement |
/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */
function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement {
const g = groupOf(card);
const locked = card.kind === "locked";
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 (g === "percent") {
const base = card.percentBase;
if (percent) {
parts.push(
selectBox(
ctx,
BASES,
locked ? null : base,
card.percentBase,
(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"),
value,
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),
plain ? " " : g === "person" ? tc("Pre_person") : tc("Pre_pick"),
value,
text("m01-easycards-unit", ` ${card.unit}`),
tc(`Post_${g}` as "Post_person"),
plain ? "" : 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 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",