Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-01 11:39:52 +09:00
10 changed files with 2116 additions and 242 deletions
@@ -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,542 @@
/* =============================================================================
* 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, { 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<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)]),
);
}
@@ -0,0 +1,97 @@
/* =============================================================================
* M01_EasyCalc_Cond.css — 조건 보고 고치기 창
* ========================================================================== */
.m01-easycond {
width: min(760px, calc(100vw - 32px));
}
.m01-easycond__lines {
display: flex;
flex-direction: column;
gap: 12px;
}
.m01-easycond__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 16px;
line-height: 1.6;
}
.m01-easycond__slot {
display: inline-flex;
align-items: center;
gap: 4px;
}
.m01-easycond__select,
.m01-easycond__num {
min-height: 36px;
padding: 4px 8px;
border: 1px solid var(--color-border, #c9ced6);
border-radius: 8px;
background: var(--color-surface, #fff);
color: inherit;
font: inherit;
}
.m01-easycond__num {
width: 7em;
text-align: right;
}
.m01-easycond__num.is-bad {
border-color: var(--color-danger, #d64545);
outline: 2px solid var(--color-danger, #d64545);
}
.m01-easycond__word {
padding: 4px 8px;
border-radius: 8px;
background: var(--color-surface-raised, #eef1f5);
font-weight: 600;
}
.m01-easycond__chip {
min-height: 36px;
padding: 4px 12px;
border: 1px dashed var(--color-accent, #3a6fd8);
border-radius: 999px;
background: transparent;
color: var(--color-accent, #3a6fd8);
font: inherit;
cursor: pointer;
}
.m01-easycond__chip:disabled {
border-color: var(--color-border, #c9ced6);
color: inherit;
cursor: default;
}
.m01-easycond__money {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
background: var(--color-surface-raised, #eef1f5);
font-size: 16px;
font-weight: 600;
}
.m01-easycond__money.is-waiting {
opacity: 0.5;
}
.m01-easycond__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.m01-easycond__cannot {
font-size: 16px;
}
@@ -0,0 +1,620 @@
/* =============================================================================
* M01_EasyCalc_Cond.ts
* 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고
* 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null
*
* 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음
* 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리)
* 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로
* 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로)
* 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄
* 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
runCalc,
type CalcAnswer,
type LogicInput,
type LogicRow,
} from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { calcLater } from "./M01_EasyCalc_Api";
import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Cond.css";
const TEXT = {
Title: ["조건 보고 고치기", "Change by conditions"],
Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"],
Part: ["조건", "Condition"],
If: ["만약", "If"],
ElseIf: ["아니고 만약", "Else if"],
Then: ["→", "→"],
Else: ["아니면 →", "Otherwise →"],
Subject: ["가", "is"],
Big: ["가운데 큰 값", "whichever is larger"],
Small: ["가운데 작은 값", "whichever is smaller"],
And: ["와", "and"],
RoundA: ["을 소수", "to"],
RoundB: ["자리까지", "decimal places,"],
Up: ["올림", "round up"],
Down: ["버림", "round down"],
Half: ["반올림", "round off"],
OpEq: ["이면", "equal to"],
OpNe: ["이 아니면", "not equal to"],
OpGt: ["보다 크면", "greater than"],
OpGe: ["이상이면", "at least"],
OpLt: ["보다 작으면", "less than"],
OpLe: ["이하이면", "at most"],
ChipTable: ["표에서 찾은 값", "Value from table"],
ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"],
ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"],
ChipCalc: ["계산한 값", "Calculated value"],
ToNumber: ["숫자로", "Use a number"],
Line: ["이 줄", "This line"],
Total: ["합계", "Total"],
Was: ["전", "before"],
Waiting: ["계산 중 …", "Calculating …"],
Use: ["이걸로", "Use this"],
Cancel: ["취소", "Cancel"],
NotNumber: ["숫자만 넣음", "Numbers only"],
} as const;
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */
/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */
type CNode = { src: string; p: [number, number] } & (
| { t: "num" | "str" | "name" | "ref"; v: string }
| { t: "call"; f: string; items: CNode[] }
| { t: "bin"; op: string; a: CNode; b: CNode }
| { t: "neg" | "paren"; a: CNode }
| { t: "field"; a: CNode; f: string }
);
const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"];
const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"];
const NAME_RE = /[\p{L}\p{N}_]+/uy;
const NUM_RE = /\d+(?:\.\d+)?/y;
/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */
function parseCond(s: string): CNode | null {
let i = 0;
const fail = (): never => {
throw new Error("parse");
};
const ws = (): void => {
while (i < s.length && /\s/.test(s[i])) i++;
};
const take = (w: string): boolean => {
ws();
if (!s.startsWith(w, i)) return false;
i += w.length;
return true;
};
const need = (w: string): void => void (take(w) || fail());
const mk = <T extends object>(start: number, body: T): CNode =>
({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode;
const sticky = (re: RegExp): string | null => {
re.lastIndex = i;
const m = re.exec(s);
if (m) i = re.lastIndex;
return m ? m[0] : null;
};
const word = (): string => (ws(), sticky(NAME_RE) ?? fail());
const quoted = (): string => {
const end = s.indexOf("'", i);
if (end < 0) fail();
const v = s.slice(i, end);
i = end + 1;
return v;
};
/** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */
const skipGroup = (): void => {
let depth = 0;
let q = false;
for (; i < s.length; i++) {
if (s[i] === "'") q = !q;
else if (!q && s[i] === "(") depth++;
else if (!q && s[i] === ")" && --depth === 0) return void i++;
}
fail();
};
/** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */
const skipRef = (): void => {
for (let depth = 0; i < s.length; i++) {
const ch = s[i];
if ("({".includes(ch)) depth++;
else if (")}".includes(ch) && depth-- === 0) return;
else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return;
else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return;
}
};
const fields = (start: number, node: CNode): CNode => {
while (take(".")) {
const f = take("'") ? quoted() : word();
node = mk(start, { t: "field", a: node, f });
}
return node;
};
const primary = (): CNode => {
ws();
const start = i;
if (take("(")) {
const a = expr();
need(")");
return fields(start, mk(start, { t: "paren", a }));
}
if (take("'")) return mk(start, { t: "str", v: quoted() });
const num = sticky(NUM_RE);
if (num) return mk(start, { t: "num", v: num });
const w = word();
if (w === "찾기" || w === "로직") {
ws();
if (s[i] !== "(") fail();
skipGroup();
return fields(start, mk(start, { t: "ref", v: w }));
}
if (COND_FUNCS.includes(w)) {
need("(");
const items = [expr()];
while (take(",")) items.push(expr());
need(")");
return fields(start, mk(start, { t: "call", f: w, items }));
}
if (s[i] === ":") {
i++;
skipRef();
return fields(start, mk(start, { t: "ref", v: w }));
}
return fields(start, mk(start, { t: "name", v: w }));
};
const unary = (): CNode => {
ws();
const start = i;
return take("-") ? mk(start, { t: "neg", a: unary() }) : primary();
};
const chain = (next: () => CNode, ops: string[], once = false): CNode => {
ws();
const start = i;
let node = next();
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
node = mk(start, { t: "bin", op, a: node, b: next() });
return node;
};
const mul = (): CNode => chain(unary, ["*", "/"]);
const add = (): CNode => chain(mul, ["+", "-"]);
const compare = (): CNode => chain(add, COMPARE_OPS, true);
const both = (): CNode => chain(compare, ["그리고"]);
const expr = (): CNode => chain(both, ["또는"]);
try {
const node = expr();
ws();
return i === s.length ? node : null;
} catch {
return null;
}
}
/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */
export type CondSlot =
| { kind: "num"; value: number }
| { kind: "text"; value: string }
| { kind: "input"; name: string }
| { kind: "table" | "calc"; src: string };
export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<=";
export interface CondTest {
left: CondSlot;
op: CondOp;
right: CondSlot;
}
export type CondShape =
| { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot }
| { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] }
| {
type: "round";
fn: "올림" | "버림" | "반올림";
value: CondSlot;
places: number;
hadPlaces: boolean;
};
/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */
export interface CondBlock {
shape: CondShape;
p: [number, number];
}
/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */
export interface CondRead {
text: string;
via: string | null;
blocks: CondBlock[];
}
const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n);
function slotOf(node: CNode, inputs: LogicInput[]): CondSlot {
const n = unparen(node);
if (n.t === "num") return { kind: "num", value: Number(n.v) };
if (n.t === "neg" && unparen(n.a).t === "num")
return { kind: "num", value: -Number(unparen(n.a).src) };
if (n.t === "str") return { kind: "text", value: n.v };
if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v };
if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기"))
return { kind: "table", src: n.src };
return { kind: "calc", src: node.src.trim() };
}
function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null {
if (n.t !== "call") return null;
const slot = (x: CNode): CondSlot => slotOf(x, inputs);
if (n.f === "만약") {
const t = unparen(n.items[0]);
if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null;
const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) };
const branch = { test, value: slot(n.items[1]) };
const rest = shapeOf(unparen(n.items[2]), inputs);
return rest?.type === "if"
? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise }
: { type: "if", branches: [branch], otherwise: slot(n.items[2]) };
}
if (n.f === "최소" || n.f === "최대")
return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null;
const places = n.items[1] ? unparen(n.items[1]) : null;
if (n.items.length > 2 || (places && places.t !== "num")) return null;
const fn = n.f as "올림" | "버림" | "반올림";
const value = slot(n.items[0]);
return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places };
}
const condNum = (n: number): string => {
const t = String(Number(n.toPrecision(12)));
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
};
function slotText(s: CondSlot): string {
if (s.kind === "num") return condNum(s.value);
if (s.kind === "text") return `'${s.value}'`;
return s.kind === "input" ? s.name : s.src;
}
/** 덩어리 → 식 글 */
export function writeCond(shape: CondShape): string {
if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`;
if (shape.type === "round") {
const v = slotText(shape.value);
return shape.places === 0 && !shape.hadPlaces
? `${shape.fn}(${v})`
: `${shape.fn}(${v}, ${condNum(shape.places)})`;
}
return shape.branches.reduceRight((acc, b) => {
const t = b.test;
return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`;
}, slotText(shape.otherwise));
}
const flat = (s: string): string => s.replace(/[\s()]+/g, "");
/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */
function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] {
if (n.t === "call") {
const shape = shapeOf(n, inputs);
if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }];
return n.items.flatMap((x) => blocksOf(x, inputs));
}
if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)];
if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs);
return [];
}
/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */
export function readCond(formula: string, row: Pick<LogicRow, "입력" | "중간">): CondRead | null {
let text = formula.trim();
let via: string | null = null;
for (let hop = 0; hop < 5; hop++) {
const mid = (row.중간 ?? []).find((m) => m.이름 === text);
if (!mid) break;
via = mid.이름;
text = mid.식.trim();
}
const node = parseCond(text);
const blocks = node ? blocksOf(node, row.입력 ?? []) : [];
return blocks.length ? { text, via, blocks } : null;
}
/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */
export function writeRead(read: CondRead): string {
let out = read.text;
for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0]))
out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]);
return out;
}
/* ── 화면 ───────────────────────────────────────────────────────────────── */
const OPS: [CondOp, keyof typeof TEXT][] = [
["==", "OpEq"],
["!=", "OpNe"],
[">", "OpGt"],
[">=", "OpGe"],
["<", "OpLt"],
["<=", "OpLe"],
];
/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */
const TABLE_MODULE = import.meta.glob<{
openEasyTable: (
card: EasyCard,
doc: EasyDoc,
trial?: Record<string, unknown>,
) => Promise<string | null>;
}>("./M01_EasyCalc_Table.ts");
/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */
const put = (slot: CondSlot, next: CondSlot): void => {
for (const k of Object.keys(slot)) delete (slot as Record<string, unknown>)[k];
Object.assign(slot, next);
};
function select(
options: { value: string; text: string }[],
value: string,
onChange: (v: string) => void,
): HTMLSelectElement {
const sel = el("select", { className: "m01-easycond__select" });
const all = options.some((o) => o.value === value)
? options
: [...options, { value, text: value }];
for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } }));
sel.value = value;
sel.addEventListener("change", () => onChange(sel.value));
return sel;
}
function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement {
const box = el("input", {
className: "m01-easycond__num",
attrs: { type: "text", inputmode: "decimal", value: condNum(value) },
});
box.addEventListener("input", () => {
const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value);
box.classList.toggle("is-bad", !ok);
box.title = ok ? "" : te("NotNumber");
if (ok) onChange(Number(box.value));
});
return box;
}
function chipLabel(slot: CondSlot, inputs: LogicInput[]): string {
if (slot.kind === "table") return te("ChipTable");
if (slot.kind !== "calc") return "";
if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc");
const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? [];
return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc");
}
/** 문장 짓기 — 고칠 때마다 changed() */
class Sentence {
constructor(
private card: EasyCard,
private doc: EasyDoc,
private changed: () => void,
private trial: Record<string, unknown>,
) {}
/** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */
slot(s: CondSlot): HTMLElement {
const host = el("span", { className: "m01-easycond__slot" });
const draw = (): void => {
host.replaceChildren();
if (s.kind === "num") {
host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed())));
return;
}
if (s.kind === "text" || s.kind === "input") {
const text = s.kind === "text" ? s.value : s.name;
host.append(el("span", { className: "m01-easycond__word", text }));
return;
}
const chip = el("button", {
className: "m01-easycond__chip",
text: chipLabel(s, this.doc.inputs),
attrs: { type: "button" },
});
const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"];
if (load && s.kind === "table")
chip.addEventListener("click", async () => {
const got = await (
await load()
).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial);
if (got) (put(s, { kind: "table", src: got }), draw(), this.changed());
});
else chip.disabled = true;
const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed());
host.append(
chip,
createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }),
);
};
draw();
return host;
}
/** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */
test(t: CondTest): HTMLElement[] {
const inputs = this.doc.inputs;
const right = el("span", { className: "m01-easycond__slot" });
const drawRight = (): void => {
const name = t.left.kind === "input" ? t.left.name : null;
const inp = inputs.find((x) => x.이름 === name);
const choices = inp?.고르기 ?? [];
const r = t.right;
if (!choices.length || (r.kind !== "text" && r.kind !== "num"))
return right.replaceChildren(this.slot(r));
const pickOne = (v: string): void => {
const asNum = choices.some((c) => typeof c === "number" && String(c) === v);
put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v });
this.changed();
};
const opts = choices.map((c) => ({ value: String(c), text: String(c) }));
right.replaceChildren(select(opts, String(r.value), pickOne));
};
drawRight();
const l = t.left;
const left =
l.kind === "input"
? select(
inputs.map((x) => ({ value: x.이름, text: x.이름 })),
l.name,
(v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()),
)
: this.slot(l);
const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) }));
const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed()));
return [left, el("span", { text: te("Subject") }), right, op];
}
/** 덩어리 하나 → 줄들 */
block(shape: CondShape): HTMLElement[] {
if (shape.type === "if")
return [
...shape.branches.map((b, i) =>
line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)),
),
line(te("Else"), this.slot(shape.otherwise)),
];
if (shape.type === "pick") {
const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)]));
const opts = [
{ value: "최대", text: te("Big") },
{ value: "최소", text: te("Small") },
];
const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed()));
return [line(...kids, fn)];
}
const ways = [
{ value: "올림", text: te("Up") },
{ value: "버림", text: te("Down") },
{ value: "반올림", text: te("Half") },
];
const setPlaces = (v: number): void => (
(shape.places = Math.max(0, Math.round(v))),
this.changed()
);
const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed()));
const places = numberBox(shape.places, setPlaces);
return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)];
}
}
const line = (...kids: (HTMLElement | string)[]): HTMLElement =>
el("div", {
className: "m01-easycond__line",
children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)),
});
/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */
function moneyView(card: EasyCard, doc: EasyDoc, trial: Record<string, unknown>) {
const box = el("div", { className: "m01-easycond__money", text: te("Waiting") });
const ho = doc.cards.filter(
(c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"),
);
const at = ho.findIndex((c) => c.id === card.id);
let first: [number | null, number | null] | null = null;
let seq = 0;
let timer = 0;
const show = (ans: CalcAnswer | null, plain: string | null): void => {
box.classList.remove("is-waiting");
if (!ans || !ans.ok)
return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : ""));
const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null;
const sums = ans.sums ?? {};
const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0);
first ??= [lineSum, total];
const part = (label: string, v: number | null, was: number | null): string =>
v === null
? ""
: `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`;
const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])];
box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t })));
};
const update = (text: string): void => {
box.classList.add("is-waiting");
const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null }));
const mine = ++seq;
const done = (ans: CalcAnswer | null, plain: string | null = null): void =>
void (mine === seq && show(ans, plain));
try {
calcLater(row, trial, done);
} catch {
// Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만)
window.clearTimeout(timer);
timer = window.setTimeout(() => {
runCalc({ key: "", inputs: trial, row }).then(
(a) => done(a),
() => done(null),
);
}, 300);
}
};
return { box, update };
}
/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */
export function openEasyCond(
card: EasyCard,
doc: EasyDoc,
trial: Record<string, unknown> = {},
): Promise<string | null> {
const read = readCond(card.qtyFormula ?? "", doc.row);
return new Promise((resolve) => {
let done = false;
const finish = (v: string | null): void => {
if (!done) ((done = true), resolve(v));
};
openModal({
title: te("Title"),
closeLabel: te("Cancel"),
dialogClass: "m01-easycond",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
if (!read)
return void body.append(
el("p", { className: "m01-easycond__cannot", text: te("Cannot") }),
);
const base = flat(writeRead(read));
const money = moneyView(card, doc, trial);
const use = createButton({
label: te("Use"),
variant: "filled",
disabled: true,
onClick: () => (finish(writeRead(read)), close()),
});
const changed = (): void => {
const text = writeRead(read);
use.disabled = flat(text) === base;
money.update(text);
};
const say = new Sentence(card, doc, changed, trial);
const lines = el("div", { className: "m01-easycond__lines" });
read.blocks.forEach((b, i) => {
if (read.blocks.length > 1)
lines.append(
el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }),
);
lines.append(...say.block(b.shape));
});
const cancel = createButton({
label: te("Cancel"),
variant: "ghost",
onClick: () => (finish(null), close()),
});
const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] });
body.append(lines, money.box, foot);
money.update(writeRead(read));
},
});
});
}
+330 -194
View File
@@ -1,8 +1,10 @@
/* =============================================================================
* M01_EasyCalc_Entry.ts
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들)
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
* 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음.
* 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다
* EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본)
* · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -16,15 +18,32 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import {
calcLater,
copyEasy,
deleteEasy,
editEasy,
listEasy,
newEasy,
openEasy,
plainError,
usesOf,
type EasyListItem,
type EasyOpened,
} from "./M01_EasyCalc_Api";
import type { EasyDoc } from "./M01_EasyCalc_Model";
import { renderCards } from "./M01_EasyCalc_Cards";
import {
checkName,
EasyHistory,
newDoc,
setEasyModelLanguage,
toDoc,
toRow,
type EasyCard,
type EasyDoc,
} from "./M01_EasyCalc_Model";
import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet";
import "./M01_EasyCalc_Style.css";
const TEXT = {
@@ -42,10 +61,8 @@ const TEXT = {
Start_Found: ["찾은 것", "Found"],
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Base: ["품셈 기본", "Standard"],
Mine: ["내가 만든 것", "Made by me"],
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
Per: ["{u} 당", "per {u}"],
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
Name: ["이름", "Name"],
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
Spec: ["크기", "Size"],
@@ -55,15 +72,27 @@ const TEXT = {
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함", "Not saved"],
BaseNote: [
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
"Standard items are read-only — use the button below to make your own copy",
],
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
Trial: ["설계 때 정하는 값", "Values set at design time"],
Copying: ["내 것으로 만드는 중", "Making your copy"],
Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"],
Cards: ["무엇이 들어가나", "What goes in"],
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
NotYet: ["준비 중", "Coming soon"],
BadLines: [
"⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨",
"Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved",
],
Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"],
Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Delete_Ok: ["지우기", "Delete"],
Deleted: ["지웠음", "Deleted"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
@@ -82,68 +111,6 @@ const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
/** 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */
const loadList = (): Promise<EasyListItem[]> =>
listEasy("").catch(() =>
fetchLogics().then((all) =>
all.map((x) => {
const isBase = !x.키.startsWith("GX");
const canOpen = x.결과단위.startsWith("원/");
return {
key: x.키,
name: x.이름,
isBase,
owner: isBase ? te("Base") : te("Mine"),
resultUnit: x.결과단위,
canOpen,
whyNot: canOpen ? null : te("CannotOpen"),
where: `${x.구분} ${x.상세구분}`.trim(),
};
}),
),
);
const loadOne = (key: string): Promise<EasyOpened> =>
openEasy(key).catch(() =>
fetchLogic(key).then((one) => ({
row: one.logic,
version: one.version,
isBase: !one.logic.키.startsWith("GX"),
trial: {},
})),
);
const plain = (error: unknown): string => {
try {
return plainError(error);
} catch {
return error instanceof Error ? error.message : String(error);
}
};
/** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
const blankRow = (resultUnit: string): LogicRow => ({
키: "",
원문번호: "",
이름: "",
결과단위: resultUnit,
출처: "자체",
입력: [],
중간: [],
호표: [],
덧줄: [],
끝수: null,
});
const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({
key,
version: opened.version,
isBase: opened.isBase,
name: opened.row.이름,
spec: "",
resultUnit: opened.row.결과단위,
inputs: opened.row.입력 ?? [],
cards: [],
row: opened.row,
});
/** 「원/㎡」 → 「㎡」 */
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
@@ -152,50 +119,82 @@ const amountOf = (resultUnit: string): string => {
return /^\d/.test(unit) ? unit : `1${unit}`;
};
/** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */
const blocksSave = (c: EasyCard): boolean =>
!c.name.trim() ||
(c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0));
/** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙:
* 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */
function fillNew(
inputs: LogicInput[],
trial: Record<string, unknown>,
known: Set<string>,
): Record<string, unknown> {
const out = { ...trial };
for (const spec of inputs) {
if (known.has(spec.이름)) continue;
known.add(spec.이름);
if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue;
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
}
return out;
}
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
export function createEasyCalcButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
}
function openEasyCalc(): void {
setEasyModelLanguage(currentLanguageIndex);
let body: HTMLElement;
let paper: EasyDoc | null = null;
let dirty = false;
/** 고치는 화면이 열려 있을 때만 */
let paper: { history: EasyHistory; save: () => Promise<void>; undo: () => void } | null = null;
let logics: EasyListItem[] | null = null;
let query = "";
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
const mayLeave = async (): Promise<boolean> =>
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
!paper?.history.dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const notYet = (): void => showToast(te("NotYet"), "info");
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(고르기 창 등 다른 창은 제 몫) */
const onKey = (ev: KeyboardEvent): void => {
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
};
/* ---------- 시작 화면 ---------- */
const listRow = (item: EasyListItem): HTMLElement => {
const ok = item.canOpen;
const row = el("button", {
className: "m01-easycalc__item",
attrs: { type: "button" },
attrs: { type: "button", title: item.where },
children: [
el("span", { className: "m01-easycalc__item-name", text: item.name }),
createTag(item.owner, item.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-easycalc__item-unit",
text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
}),
...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]),
...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]),
],
});
row.disabled = !ok;
row.title = item.where;
if (item.whyNot) row.title = item.whyNot;
row.disabled = !item.canOpen;
row.addEventListener("click", () => void openPaper(item.key));
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
dirty = false;
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
const mineHost = el("div", { className: "m01-easycalc__list" });
const foundHost = el("div", { className: "m01-easycalc__list" });
@@ -252,9 +251,9 @@ function openEasyCalc(): void {
search.input.focus();
try {
logics ??= await loadList();
logics ??= await listEasy("");
} catch (error) {
status.textContent = te("LoadFailed", { v: plain(error) });
status.textContent = te("LoadFailed", { v: plainError(error) });
return;
}
const mine = logics.filter((x) => !x.isBase);
@@ -266,85 +265,73 @@ function openEasyCalc(): void {
drawFound();
};
/* ---------- 고치는 화면 ---------- */
const openBlank = (): void => {
const row = blankRow(unitChoices()[0] ?? "원/㎡");
paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null };
showPaper();
};
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
try {
paper = docOf(await loadOne(key), key);
showPaper();
} catch (error) {
showToast(plain(error), "error");
void showStart();
}
};
/** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */
/** 단위 고르기 후보 — 목록에 다섯 번 넘게 쓰인 「원/…」 차례 · 설명 붙은 드문 단위는 뺌(연 로직 것은 따로 더함) */
const unitChoices = (): string[] => {
const count = new Map<string, number>();
for (const x of logics ?? []) {
if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u);
return [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
};
const showPaper = (): void => {
const p = paper;
if (!p) return;
const readOnly = p.isBase;
/* ---------- 고치는 화면 ---------- */
const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {});
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
try {
const opened = await openEasy(key);
showPaper(
toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }),
opened.trial,
);
} catch (error) {
showToast(plainError(error), "error");
void showStart();
}
};
const showPaper = (first: EasyDoc, firstTrial: Record<string, unknown>): void => {
let doc = first;
let trial = firstTrial;
let ans: CalcAnswer | null = null;
let history = new EasyHistory();
history.push(doc);
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
unsaved.hidden = true;
const total = el("div", { className: "m01-easycalc__total" });
const drawTotal = (): void => {
total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" });
};
const touched = (): void => {
dirty = true;
unsaved.hidden = false;
};
const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } });
const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" });
const cardsHost = el("div", { className: "m01-easycalc__cards" });
const sheetHost = el("div");
let trialShape = "";
/** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */
const known = new Set(doc.inputs.map((i) => i.이름));
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: p.name,
onInput: (v) => {
p.name = v;
touched();
},
onInput: (v) => edit({ ...doc, name: v }),
});
const spec = createInputField({
label: te("Spec"),
value: p.spec,
onInput: (v) => {
p.spec = v;
touched();
},
});
const units = unitChoices();
if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit);
name.root.classList.add("m01-easycalc__name");
const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) });
const unit = createSelectField({
label: te("Unit"),
options: units.map((u) => ({ value: u, text: unitOf(u) })),
value: p.resultUnit,
disabled: readOnly,
onChange: (v) => {
p.resultUnit = v;
drawTotal();
touched();
},
options: [],
onChange: (v) => edit({ ...doc, resultUnit: v }),
});
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() });
const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
onClick: () => void doDelete(),
});
name.input.disabled = readOnly;
spec.input.disabled = readOnly;
name.root.classList.add("m01-easycalc__name");
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
undo.disabled = true;
const close = createButton({
label: te("Close"),
variant: "ghost",
@@ -353,62 +340,210 @@ function openEasyCalc(): void {
if (await mayLeave()) void showStart();
})(),
});
const actions = readOnly
? [close]
: [
undo,
createButton({ label: te("Save"), onClick: notYet }),
...(p.key && !p.isBase
? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })]
: []),
close,
];
const section = (title: string, extra = ""): HTMLElement =>
el("section", {
className: `m01-easycalc__part ${extra}`.trim(),
children: [
el("h4", { text: title }),
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
],
});
const sheet = el("details", {
className: "m01-easycalc__part m01-easycalc__sheet",
const actions = el("div", { className: "m01-easycalc__actions" });
const baseBar = el("div", {
className: "m01-easycalc__base",
children: [
el("summary", { text: te("Sheet") }),
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
el("p", { text: te("BaseNote") }),
createBigButton(te("BaseCopy"), () => void doCopy()),
],
});
drawTotal();
/** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */
const fillStrip = (): void => {
const units = unitChoices();
if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit);
unit.setOptions(
units.map((u) => ({ value: u, text: unitOf(u) })),
doc.resultUnit,
);
name.input.value = doc.name;
spec.input.value = doc.spec;
for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase;
actions.replaceChildren(
...(doc.isBase ? [] : [undo, saveButton]),
...(doc.key && !doc.isBase ? [deleteButton] : []),
close,
);
baseBar.hidden = !doc.isBase;
};
/** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */
const draw = (pending: boolean, answer = false): void => {
const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—";
total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value });
total.classList.toggle("is-pending", pending);
sheetHost.classList.toggle("is-pending", pending);
unsaved.hidden = !history.dirty;
undo.disabled = !history.canUndo;
const shape = JSON.stringify(doc.inputs);
if (shape !== trialShape) {
trialShape = shape;
trial = fillNew(doc.inputs, trial, known);
renderTrialStrip(trialHost, doc.inputs, trial, (next) => {
trial = next;
calc();
});
}
drawCards(answer);
renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans);
};
/** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게).
* doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */
let waiting = false;
const drawCards = (answer = false): void => {
const active = document.activeElement;
if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) {
if (!waiting) {
waiting = true;
cardsHost.addEventListener(
"focusout",
() =>
setTimeout(() => {
waiting = false;
drawCards(true);
}),
{ once: true },
);
}
return;
}
renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial });
};
const calc = (): void => {
draw(true);
calcLater(toRow(doc), trial, (next, plain) => {
ans = next;
note.textContent = plain ?? "";
draw(false, true);
});
};
const edit = (next: EasyDoc): void => {
doc = next;
history.push(doc);
calc();
};
const doUndo = (): void => {
const back = history.undo();
if (!back) return;
doc = back;
fillStrip();
calc();
};
/** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */
const reopen = (next: EasyDoc): void => {
doc = next;
history = new EasyHistory();
history.push(doc);
logics = null;
fillStrip();
calc();
};
const doSave = async (): Promise<void> => {
if (doc.isBase || busy) return;
const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name);
const bad = checkName(doc.name, mine);
name.setError(bad ?? undefined);
if (bad) return void name.input.focus();
if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines"));
busy = true;
saveButton.disabled = true;
note.textContent = te("Saving");
try {
const row = toRow(doc);
const saved =
doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row);
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
note.textContent = "";
showToast(te("Saved"), "success");
} catch (error) {
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
} finally {
busy = false;
saveButton.disabled = false;
}
};
const doCopy = async (): Promise<void> => {
if (!doc.key || busy) return;
busy = true;
note.textContent = te("Copying");
try {
const saved = await copyEasy(doc.key);
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
showToast(te("Copied"), "success");
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
}
};
const doDelete = async (): Promise<void> => {
if (!doc.key || !doc.version || doc.isBase || busy) return;
busy = true;
deleteButton.disabled = true;
note.textContent = te("Uses_Wait");
try {
const uses = await usesOf(doc.key);
const blocking = uses.filter((u) => u.key);
if (blocking.length) {
note.textContent = te("Uses_Block", {
v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "),
});
return;
}
note.textContent = uses.map((u) => u.name).join(" · ");
if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) {
return;
}
await deleteEasy(doc.key, doc.version);
logics = null;
paper = null;
showToast(te("Deleted"), "success");
void showStart();
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
deleteButton.disabled = false;
}
};
paper = {
get history() {
return history;
},
save: doSave,
undo: doUndo,
};
body.replaceChildren(
el("div", {
className: "m01-easycalc__strip",
children: [
name.root,
spec.root,
unit.root,
total,
unsaved,
el("div", { className: "m01-easycalc__actions", children: actions }),
],
children: [name.root, spec.root, unit.root, total, unsaved, actions],
}),
note,
baseBar,
trialHost,
el("section", {
className: "m01-easycalc__part",
children: [el("h4", { text: te("Cards") }), cardsHost],
}),
el("details", {
className: "m01-easycalc__part m01-easycalc__sheet",
children: [el("summary", { text: te("Sheet") }), sheetHost],
}),
...(readOnly
? [
el("div", {
className: "m01-easycalc__base",
children: [
el("p", { text: te("BaseNote") }),
createBigButton(te("BaseCopy"), notYet),
],
}),
]
: []),
section(te("Trial"), "m01-easycalc__trial"),
section(te("Cards"), "m01-easycalc__cards"),
sheet,
);
if (!readOnly) name.input.focus();
fillStrip();
calc();
if (!doc.isBase) name.input.focus();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
@@ -419,6 +554,7 @@ function openEasyCalc(): void {
beforeClose: mayLeave,
mount: (host) => {
body = host;
(host.parentElement ?? host).addEventListener("keydown", onKey);
void showStart();
},
});
+58 -21
View File
@@ -15,12 +15,14 @@ import {
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api";
import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api";
import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api";
import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api";
import type { PickResult } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Pick.css";
type Kind = PickResult["kind"];
/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
const TEXT = {
Title: ["넣을 것 고르기", "Pick what to add"],
@@ -64,6 +66,8 @@ const te = (key: keyof typeof TEXT, vars: Record<string, string | number> = {}):
);
const won = (n: number): string => Math.round(n).toLocaleString("ko-KR");
/** 목록에 그릴 줄 수 — 더 찾으려면 이름을 쳐서 좁힘 */
const SHOW = 100;
const MAT_FILE = "재료_자재품목.json";
/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */
@@ -94,6 +98,23 @@ interface Source {
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
}
/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 ·
* 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */
const byName = <T extends { 이름?: string }>(items: T[], q: string): T[] => {
const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase();
const w = squash(q);
const rank = (it: T): number => {
const n = squash(it.이름 ?? "");
if (n === w) return 0;
if (!n.startsWith(w)) return 3;
return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1;
};
return items
.map((it, i) => ({ it, i, r: rank(it) }))
.sort((a, b) => a.r - b.r || a.i - b.i)
.map((x) => x.it);
};
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const SOURCES: Partial<Record<Kind, Source>> = {
@@ -133,14 +154,14 @@ const SOURCES: Partial<Record<Kind, Source>> = {
const found = f.q
? await fetchPick("price", f.q).then((r) => ({
total: r.total,
items: r.items
.filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub)
.map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })),
items: byName(r.items, f.q)
.filter((it) => !f.sub || (it as Brief).구분 === f.sub)
.map((it) => ({ ...it, 구분: (it as Brief).구분 })),
}))
: await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then(
(r) => ({
total: r.total,
items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })),
items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })),
}),
);
return {
@@ -151,7 +172,7 @@ const SOURCES: Partial<Record<Kind, Source>> = {
const unit = typeof it.단위 === "string" ? it.단위 : "";
const price = num(it.값);
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
const detail = (it as { 상세구분?: string }).상세구분;
const detail = (it as Brief).상세구분;
if (detail) element["상세구분"] = detail;
element["검색어"] = `${name} ${spec}`.trim();
return {
@@ -213,13 +234,20 @@ const SOURCES: Partial<Record<Kind, Source>> = {
price: null,
priceText: "",
tag: e.owner,
make: async () => ({
kind: "logic",
name: e.name,
spec: "",
unit,
element: `로직(${e.key})`,
}),
make: async () => {
// 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴)
const needInputs = (await fetchLogic(e.key)).logic.입력;
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
return {
kind: "logic",
name: e.name,
spec: "",
unit,
element: `로직(${e.key}${rest})`,
needInputs,
};
},
};
}),
};
@@ -228,7 +256,10 @@ const SOURCES: Partial<Record<Kind, Source>> = {
};
/** 물음 셋 — 고른 것 · 취소는 null */
export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
export function openEasyPick(
kind?: Kind,
opts: { qty?: number | null } = {},
): Promise<PickResult | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (r: PickResult | null): void => {
@@ -294,16 +325,17 @@ export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
return void list.replaceChildren(
el("p", { className: "m01-easypick__muted", text: te("Hint") }),
);
count.textContent = te("Count", { n: found.total, m: found.items.length });
const shown = found.items.slice(0, SHOW);
count.textContent = te("Count", { n: found.total, m: shown.length });
list.replaceChildren(
...(found.items.length
? found.items.map((c) =>
...(shown.length
? shown.map((c) =>
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
)
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
);
} catch {
if (mine === seq) showToast(te("Failed"), "error");
} catch (e) {
if (mine === seq) showToast(plainError(e), "error");
}
};
if (src.grouped) bar.unshift(...subFilters(filter, load));
@@ -334,6 +366,7 @@ export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
placeholder: te("QtyPlace"),
});
qty.input.inputMode = "decimal";
if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움
const rough = el("p", { className: "m01-easypick__rough" });
const parse = (): number | null => {
const n = Number(qty.input.value.trim().replace(/,/g, ""));
@@ -348,7 +381,11 @@ export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
const submit = async (): Promise<void> => {
const n = parse();
if (n === null) return qty.setError(te("QtyBad"));
done({ ...(await c.make()), qty: n });
try {
done({ ...(await c.make()), qty: n });
} catch (e) {
qty.setError(plainError(e));
}
};
qty.input.addEventListener("input", refresh);
qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit());
@@ -157,3 +157,19 @@
cursor: pointer;
font-weight: 600;
}
/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */
.m01-easycalc .is-pending {
opacity: 0.45;
}
.m01-easycalc__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m01-easycalc__note:empty {
display: none;
}
@@ -124,6 +124,22 @@
opacity: 0.5;
}
.m01-easytable__hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
font-size: var(--text-body-sm);
}
.m01-easytable__hint:empty {
display: none;
}
.m01-easytable__warn {
margin: 0;
color: var(--color-danger);
+47 -27
View File
@@ -45,6 +45,10 @@ const TEXT = {
Total: ["합계", "Total"],
Wait: ["계산 중", "Calculating"],
Stopped: ["계산 멈춤", "Calculation stopped"],
DesignHint: [
"이 값은 설계 때 정함 — 늘 이 줄로 하려면",
"This value is set at design — to always use this row,",
],
Mismatch: [
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
"The picked row isn't matched by these conditions",
@@ -351,6 +355,8 @@ interface CallState {
picked: number | null;
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
tryValues: Record<string, unknown>;
/** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */
designHint: string[];
}
const showCell = (v: Cell): string => {
@@ -367,7 +373,7 @@ function startState(call: FindCall, doc: EasyDoc): CallState {
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
});
return { conds, col: call.col, picked: null, tryValues: {} };
return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] };
}
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
@@ -465,6 +471,7 @@ async function mountTable(
const grid = el("div", { className: "m01-easytable__grid" });
const money = el("div", { className: "m01-easytable__money" });
const warn = el("p", { className: "m01-easytable__warn" });
const hint = el("p", { className: "m01-easytable__hint" });
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
const cancel = createButton({
label: tt("Cancel"),
@@ -491,6 +498,7 @@ async function mountTable(
const st = states[now];
const row = table?.줄[idx];
if (!table || !row) return;
st.designHint = [];
for (const key of Object.keys(table.조건)) {
if (row[key] === undefined) continue;
let c = st.conds.find((x) => x.key === key);
@@ -504,6 +512,7 @@ async function mountTable(
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
: row[key];
st.tryValues[c.input] = n;
st.designHint.push(key);
} else {
c.mode = "fix";
c.fix = row[key];
@@ -521,9 +530,36 @@ async function mountTable(
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
}
c.mode = mode;
states[now].designHint = states[now].designHint.filter((k) => k !== c.key);
void refresh();
};
/** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */
const fixPicked = (): void => {
const st = states[now];
const row = st.picked === null ? null : tbl[now]?.줄[st.picked];
if (!row) return;
for (const c of st.conds)
if (st.designHint.includes(c.key)) {
c.mode = "fix";
c.fix = row[c.key];
}
st.designHint = [];
void refresh();
};
const paintHint = (): void => {
const st = states[now];
hint.replaceChildren(
...(st.picked !== null && st.designHint.length
? [
el("span", { text: `${tt("DesignHint")} ` }),
createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }),
]
: []),
);
};
const paintConds = (): void => {
const st = states[now];
condBox.replaceChildren(
@@ -579,10 +615,7 @@ async function mountTable(
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
const rows = table.줄.map((row, idx) => {
const tr = el("tr", {
className: [
idx === hit && !busy ? "is-hit" : "",
idx === st.picked ? "is-picked" : "",
].join(" "),
className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`,
children: [
...conds.map((k) => el("td", { text: showCell(row[k]) })),
...vals.map((k) =>
@@ -600,28 +633,13 @@ async function mountTable(
});
return tr;
});
grid.replaceChildren(
el("table", {
children: [
el("thead", {
children: [
el("tr", {
children: [
...conds.map((k) => th(k)),
...vals.map((k) =>
th(
`${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`,
k === st.col ? "is-col" : "",
),
),
],
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k);
const top = [
...conds.map((k) => th(k)),
...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")),
];
const thead = el("thead", { children: [el("tr", { children: top })] });
grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] }));
};
const paint = (): void => {
@@ -646,6 +664,7 @@ async function mountTable(
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
);
paintConds();
paintHint();
paintGrid();
const picked = states[now].picked;
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
@@ -667,6 +686,7 @@ async function mountTable(
which,
head,
condBox,
hint,
grid,
money,
warn,
+198
View File
@@ -0,0 +1,198 @@
"""M01 쉬운 단가산출 조건 보고 고치기 시험(34-3c) — 읽기 · 짜기 구역을 떼어 Node 로 돌림 · 화면 조작은 ORCA.
· 견본 식은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 만약(사슬) · 최소/최대 · 올림 · 식 속 덩어리 · 중간 값 따라가기 · 못 읽는 꼴 · 고친 뒤 짜기 왕복
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
COND = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Cond.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
INPUTS = [
{"이름": "비탈구배", "고르기": ["해당 없음", "1:1.0", "1:1.5"]},
{"이름": "뒷길이", "단위": "㎝"},
{"이름": "교환측", "고르기": ["양측", "한측"]},
{"이름": "층수", "고르기": [1, 2, 3]},
]
ROW = {"입력": INPUTS, "중간": [{"이름": "석공품", "식": "만약(뒷길이 > 40, 0.4, 0.3)"}]}
def _run(body: str, *args: object) -> object:
"""읽기 · 짜기 구역 + 시험 글 — `A` = 넘긴 인자 · `out(x)` 로 답"""
src = COND.read_text(encoding="utf-8")
part = src[src.index("/* ── 읽기 · 짜기") : src.index("/* ── 화면")]
script = (
"type LogicInput = any; type LogicRow = any;\n"
+ part
+ "\nconst A = JSON.parse(process.argv[1]);\n"
+ "const out = (x: unknown) => console.log(JSON.stringify(x));\n"
+ body
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(list(args))],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _read(formula: str) -> dict | None:
return _run("out(readCond(A[0], A[1]));", formula, ROW)
def _edit(formula: str, js: str) -> str:
"""읽은 뒤 `js` 로 덩어리(`B` = 첫 덩어리 꼴)를 고치고 다시 짬"""
return _run(
f"const R = readCond(A[0], A[1]); const B = R.blocks[0].shape; {js}; out(writeRead(R));",
formula,
ROW,
)
# ── 읽기 ───────────────────────────────────────────────────────────────────
def test_만약_한겹_설계값_표() -> None:
f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)"
r = _read(f)
assert r["via"] is None and r["text"] == f and len(r["blocks"]) == 1
b = r["blocks"][0]
assert b["p"] == [0, len(f)]
assert b["shape"] == {
"type": "if",
"branches": [
{
"test": {
"left": {"kind": "input", "name": "비탈구배"},
"op": "==",
"right": {"kind": "text", "value": "해당 없음"},
},
"value": {"kind": "num", "value": 0},
}
],
"otherwise": {"kind": "table", "src": "찾기(QF000001, 구배=비탈구배).값"},
}
def test_만약_사슬() -> None:
r = _read("만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))")
shape = r["blocks"][0]["shape"]
assert [x["value"] for x in shape["branches"]] == [
{"kind": "num", "value": 2},
{"kind": "num", "value": 3.5},
]
assert shape["otherwise"] == {"kind": "num", "value": -1}
assert shape["branches"][1]["test"]["right"] == {"kind": "num", "value": 2}
def test_최대_올림() -> None:
pick = _read("최대(시공량 * 0.5, 3)")["blocks"][0]["shape"]
assert pick == {
"type": "pick",
"fn": "최대",
"items": [{"kind": "calc", "src": "시공량 * 0.5"}, {"kind": "num", "value": 3}],
}
up = _read("올림(찾기(QC000001).값 / 시공량, 2)")["blocks"][0]["shape"]
assert (up["type"], up["fn"], up["places"], up["hadPlaces"]) == ("round", "올림", 2, True)
assert up["value"] == {"kind": "calc", "src": "찾기(QC000001).값 / 시공량"}
assert _read("버림(뒷길이)")["blocks"][0]["shape"]["hadPlaces"] is False
def test_식_속_덩어리와_중간값() -> None:
f = "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)"
r = _read(f)
assert len(r["blocks"]) == 1 and r["blocks"][0]["p"] == [f.index("만약"), len(f)]
many = _read("(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100")
assert [b["shape"]["type"] for b in many["blocks"]] == ["if", "pick"]
via = _read("석공품")
assert via["via"] == "석공품" and via["text"] == "만약(뒷길이 > 40, 0.4, 0.3)"
@pytest.mark.parametrize(
"formula",
[
"만약(뒷길이 > 40 그리고 층수 < 2, 1, 0)",
"찾기(QC000001, 항목=만약(교환측 == '양측', 'a', 'b')).수량",
"찾기(QC000001).값 * 2",
"0.5",
"만약(뒷길이 > 40, 1",
"",
],
ids=["그리고", "찾기속", "조건없음", "숫자", "괄호깨짐", "빈글"],
)
def test_못_읽는_꼴(formula: str) -> None:
assert _read(formula) is None
# ── 짜기 왕복 ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"formula",
[
"만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)",
"만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))",
"최소(시공량 * 0.5, 3, 뒷길이)",
"반올림(찾기(QC000001).값 / 시공량, 2)",
"찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65) * (1 + 할증 / 100)",
"(용접공 * LB000012 + 특별인부 * LB000003) * 만약(교환측 == '양측', 22, 18) / 100",
],
)
def test_안_고치면_같은_글(formula: str) -> None:
assert _run("out(writeRead(readCond(A[0], A[1])));", formula, ROW) == formula
def test_고친_뒤_짜기() -> None:
f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)"
assert (
_edit(f, "B.branches[0].test.right.value = '1:1.5'; B.branches[0].test.op = '!='")
== "만약(비탈구배 != '1:1.5', 0, 찾기(QF000001, 구배=비탈구배).값)"
)
assert (
_edit(f, "B.otherwise = { kind: 'num', value: 0.25 }")
== "만약(비탈구배 == '해당 없음', 0, 0.25)"
)
assert (
_edit(
"찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)",
"B.branches[0].value.value = 1.1",
)
== "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1.1, 0.65)"
)
assert (
_edit("최대(시공량 * 0.5, 3)", "B.fn = '최소'; B.items[1].value = 4")
== "최소(시공량 * 0.5, 4)"
)
assert _edit("올림(뒷길이 / 3)", "B.places = 1; B.fn = '반올림'") == "반올림(뒷길이 / 3, 1)"
assert _edit("석공품", "B.branches[0].test.left.name = '층수'; B.otherwise.value = 0.2") == (
"만약(층수 > 40, 0.4, 0.2)"
)
def test_둘째_덩어리_고침() -> None:
f = "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100"
got = _run(
"const R = readCond(A[0], A[1]); R.blocks[1].shape.items[1].value = 45; out(writeRead(R));",
f,
ROW,
)
assert got == "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 45)) / 100"
# ── 화면 글 ─────────────────────────────────────────────────────────────────
def test_화면_글에_금지_낱말_없음() -> None:
src = COND.read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert len(korean) >= 30
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
assert bad not in text, (bad, text)