From de2720c46b1851b85e889219ab2cc1564c699fc3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:37:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M01-=EC=89=AC=EC=9A=B4=EB=8B=A8?= =?UTF-8?q?=EA=B0=80):=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=20Cards=20=EC=B6=94=EA=B0=80=20-=20=EB=AC=B6=EC=9D=8C=C2=B7?= =?UTF-8?q?=EC=86=8C=EA=B3=84=C2=B7=EB=AC=B8=EC=9E=A5=20=EC=B9=B8=C2=B7?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EC=B9=B4=EB=93=9C=C2=B7=EC=A7=80=EC=9B=80?= =?UTF-8?q?=C2=B7=E2=96=B2=E2=96=BC=C2=B7=EA=B2=BD=EA=B3=A0=C2=B7=EB=8D=94?= =?UTF-8?q?=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- .../EasyCalc/M01_EasyCalc_Cards.css | 192 +++++++ M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts | 517 ++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css new file mode 100644 index 000000000..2d5692659 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css @@ -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%; + } +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts new file mode 100644 index 000000000..4e6d5aebd --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts @@ -0,0 +1,517 @@ +/* ============================================================================= + * M01_EasyCalc_Cards.ts + * 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추 + * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { + addCard, + checkCard, + moveCard, + removeCard, + updateCard, + type CostKind, + type EasyCard, + type EasyDoc, + type PickResult, +} from "./M01_EasyCalc_Model"; +import { openEasyPick } from "./M01_EasyCalc_Pick"; +import "./M01_EasyCalc_Cards.css"; + +const TEXT = { + Group_person: ["사람", "Workers"], + Group_material: ["재료", "Materials"], + Group_machine: ["기계·경비", "Machines"], + Group_logic: ["다른 일위대가", "Other unit prices"], + Group_percent: ["덧붙임", "Add-ons"], + Subtotal: ["소계", "Subtotal"], + Won: ["원", "KRW"], + Empty: ["아직 없음", "none yet"], + Pending: ["계산 중", "calculating"], + Tag_person: ["사람", "Worker"], + Tag_material: ["재료", "Material"], + Tag_machine: ["기계", "Machine"], + Tag_logic: ["다른 일위대가", "Other unit price"], + Tag_percent: ["덧붙임", "Add-on"], + Pre_person: [" 이 ", " works "], + Post_person: [" 일함", ""], + Pre_pick: [" 를 ", " uses "], + Post_material: [" 씀", ""], + Post_machine: [" 씀", ""], + Post_logic: ["", ""], + Pre_percent: [" 의 ", " of "], + Mid_percent: [" % 를 ", " % as "], + Post_percent: [" 로 더함", ""], + Slot: ["넣을 자리", "put into"], + Pick_Empty: ["고르기", "choose"], + Repick: ["다른 것으로 다시 고르기", "Choose another"], + Eg_qty: ["예: 0.072", "e.g. 0.072"], + Eg_percent: ["예: 2", "e.g. 2"], + Eg_name: ["예: 공구손료", "e.g. tool wear"], + Qty: ["수량", "Amount"], + Name: ["이름", "Name"], + Auto: ["자동", "auto"], + Locked: ["잠김", "Locked"], + ToNumber: ["숫자로 바꾸기", "Make it a number"], + ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"], + ToNumber_Ask: [ + "자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음", + "Freeze the auto value as a number", + ], + ToNumber_Ok: ["바꾸기", "Convert"], + Remove: ["지움", "Remove"], + Up: ["위로", "Move up"], + Down: ["아래로", "Move down"], + Add_person: ["+ 사람", "+ Worker"], + Add_material: ["+ 재료", "+ Material"], + Add_machine: ["+ 기계", "+ Machine"], + Add_logic: ["+ 다른 일위대가", "+ Other unit price"], + Add_percent: ["+ 덧붙임", "+ Add-on"], + Base_노무비: ["사람 품값", "labor cost"], + Base_재료비: ["재료값", "material cost"], + Base_경비: ["기계·경비", "machine & expenses"], + Base_사람줄합: ["사람 줄의 합", "sum of worker lines"], +} as const; + +const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +type Group = "person" | "material" | "machine" | "logic" | "percent"; +const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"]; +type PercentBase = NonNullable; +const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"]; +const COSTS: CostKind[] = ["노무비", "재료비", "경비"]; + +interface Ctx { + doc: EasyDoc; + lines: Map; + readOnly: boolean; + on: { change(doc: EasyDoc): void }; + /** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */ + badText: Map; +} + +const badTexts = new WeakMap>(); +const closedGroups = new WeakMap>(); +let blankSeq = 0; +let picking = false; + +const groupOf = (card: EasyCard): Group => { + if (card.kind !== "locked") return card.kind; + if (card.source?.part === "덧줄") return "percent"; + const kind = (card.raw as { 종류?: string } | null)?.종류; + return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic"; +}; + +const partOf = (card: EasyCard): "호표" | "덧줄" => + card.source?.part ?? (card.kind === "percent" ? "덧줄" : "호표"); + +/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */ +function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map { + const out = new Map(); + 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): void { + ctx.on.change(updateCard(ctx.doc, card.id, patch)); +} + +/** 같은 묶음 안 이웃을 지나갈 때까지 한 칸씩 — 묶음 밖 카드가 사이에 있어도 눈에 보이게 옮겨짐 */ +function moveInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc { + const same = doc.cards.filter((c) => groupOf(c) === groupOf(card)); + const neighbor = same[same.indexOf(card) + step]; + if (!neighbor) return doc; + const at = (d: EasyDoc, id: string): number => d.cards.findIndex((c) => c.id === id); + let next = doc; + for (let n = doc.cards.length; n > 0; n--) { + if ((at(next, card.id) - at(next, neighbor.id)) * step >= 0) break; + const moved = moveCard(next, card.id, step); + if (moved === next) break; + next = moved; + } + return next; +} + +async function repick(ctx: Ctx, card: EasyCard): Promise { + if (picking || card.kind === "locked" || card.kind === "percent") return; + picking = true; + try { + const pick = await openEasyPick(card.kind); + if (!pick) return; + let next = updateCard(ctx.doc, card.id, { + name: pick.name, + spec: pick.spec, + unit: pick.unit, + element: pick.element, + }); + const have = new Set(next.inputs.map((i) => i.이름)); + const extra = (pick.needInputs ?? []).filter((i) => !have.has(i.이름)); + if (extra.length) next = { ...next, inputs: [...next.inputs, ...extra] }; + ctx.on.change(next); + } finally { + picking = false; + } +} + +async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise { + if (picking) return; + picking = true; + try { + const pick = await openEasyPick(kind); + if (pick) ctx.on.change(addCard(ctx.doc, pick)); + } finally { + picking = false; + } +} + +function addBlankPercent(ctx: Ctx): void { + const card: EasyCard = { + id: `blank-${++blankSeq}`, + kind: "percent", + name: "", + spec: "", + unit: "%", + qty: null, + qtyFormula: null, + cost: "경비", + percentBase: "노무비", + element: null, + lockedWhy: null, + source: null, + raw: null, + }; + ctx.on.change({ ...ctx.doc, cards: [...ctx.doc.cards, card] }); +} + +async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise { + const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`; + if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) { + patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null }); + } +} + +/* ------------------------------ 칸 만들기 ------------------------------ */ + +const text = (cls: string, value: string): HTMLSpanElement => + el("span", { className: cls, text: value }); + +function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement { + const bad = ctx.badText.get(card.id); + const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty); + const input = el("input", { + className: "ui-input m01-easycards-qty", + attrs: { + type: "text", + inputmode: "decimal", + placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"), + "aria-label": tc("Qty"), + }, + }); + input.value = shown; + if (ctx.readOnly) input.readOnly = true; + input.addEventListener("change", () => { + const qty = parseQty(input.value); + if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value); + else ctx.badText.delete(card.id); + if (Object.is(qty, card.qty)) return; + patchCard(ctx, card, { qty }); + }); + return input; +} + +function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement { + if (card.kind === "percent") { + const input = el("input", { + className: "ui-input m01-easycards-text", + attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") }, + }); + input.value = card.name; + if (ctx.readOnly) input.readOnly = true; + input.addEventListener("change", () => { + if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() }); + }); + return input; + } + const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty"); + if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label); + const button = el("button", { + className: "m01-easycards-name m01-easycards-name--pick", + text: `${label} ▾`, + attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` }, + }); + button.addEventListener("click", () => void repick(ctx, card)); + return button; +} + +function selectBox( + ctx: Ctx, + values: T[], + current: T | null, + label: (v: T) => string, + onPick: (v: T) => void, + aria: string, +): HTMLElement { + if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : ""); + const select = el("select", { + className: "ui-select m01-easycards-select", + attrs: { "aria-label": aria }, + }); + if (current === null) + select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") })); + for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) })); + select.value = current ?? ""; + select.addEventListener("change", () => { + if (select.value) onPick(select.value as T); + }); + return select; +} + +function amountText(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`); + return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`); +} + +function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null { + if (ctx.readOnly) return null; + const at = group.indexOf(card); + const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => { + const b = createButton({ + label: glyph, + variant: "ghost", + size: "sm", + disabled: off, + onClick: () => ctx.on.change(moveInGroup(ctx.doc, card, step)), + }); + b.setAttribute("aria-label", label); + return b; + }; + return el("div", { + className: "m01-easycards-tools", + children: [ + move(tc("Up"), "▲", -1, at <= 0), + move(tc("Down"), "▼", 1, at >= group.length - 1), + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)), + }), + ], + }); +} + +/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */ +function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement { + const g = groupOf(card); + const locked = card.kind === "locked"; + const name = nameWidget(ctx, card); + const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)]; + + if (g === "percent") { + const base = card.percentBase; + parts.push( + selectBox( + ctx, + BASES, + locked ? null : base, + (v) => tc(`Base_${v}`), + (v) => patchCard(ctx, card, { percentBase: v }), + tc("Slot"), + ), + tc("Pre_percent"), + locked ? lockedValue(ctx, card) : qtyInput(ctx, card), + locked ? "" : tc("Mid_percent"), + name, + tc("Post_percent"), + ); + } else { + parts.push( + name, + g === "person" ? tc("Pre_person") : tc("Pre_pick"), + locked ? lockedValue(ctx, card) : qtyInput(ctx, card), + text("m01-easycards-unit", ` ${card.unit}`), + tc(`Post_${g}` as "Post_person"), + ); + } + + const slot = + g === "percent" && !locked + ? el("span", { + className: "m01-easycards-slot", + children: [ + ` · ${tc("Slot")} `, + selectBox( + ctx, + COSTS, + card.cost, + (v) => tc(`Base_${v}`), + (v) => patchCard(ctx, card, { cost: v }), + tc("Slot"), + ), + ], + }) + : null; + + const head = el("div", { + className: "m01-easycards-line", + children: [ + el("div", { + className: "m01-easycards-sentence", + children: slot ? [...parts, slot] : parts, + }), + amountText(ctx, card), + toolButtons(ctx, card, group) ?? "", + ], + }); + const rows: HTMLElement[] = [head]; + + const warn = warnOf(ctx, card); + if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` })); + if (locked) rows.push(lockedNote(ctx, card)); + return el("li", { + className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`, + children: rows, + }); +} + +/** 잠금 카드 값 자리 — 「자동 0.50」 */ +function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + const value = line ? formatNumber(line.수량) : tc("Pending"); + return text("m01-easycards-auto", `(${tc("Auto")} ${value})`); +} + +function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + const note: (HTMLElement | string)[] = [ + text("m01-easycards-lock", `🔒 ${tc("Locked")}`), + text("m01-easycards-why", card.lockedWhy ?? ""), + ]; + if (!ctx.readOnly) { + const button = createButton({ + label: tc("ToNumber"), + variant: "ghost", + size: "sm", + disabled: !line, + onClick: () => line && void lockToNumber(ctx, card, line.수량), + }); + if (!line) button.title = tc("ToNumber_Wait"); + note.push(button); + } + return el("div", { className: "m01-easycards-locknote", children: note }); +} + +/* ------------------------------ 묶음 · 전체 ------------------------------ */ + +function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement { + const title = tc(`Group_${group}`); + if (!cards.length) { + return el("section", { + className: "m01-easycards-group m01-easycards-group--empty", + children: [ + text("m01-easycards-group__title", title), + text("m01-easycards-group__none", tc("Empty")), + ], + }); + } + const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l); + const sum = + found.length === cards.length + ? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}` + : `${tc("Subtotal")} ${tc("Pending")}`; + const details = el("details", { + className: "m01-easycards-group", + children: [ + el("summary", { + className: "m01-easycards-group__head", + children: [ + text("m01-easycards-group__title", title), + text( + `m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`, + sum, + ), + ], + }), + el("ul", { + className: "m01-easycards-list", + children: cards.map((c) => cardView(ctx, c, cards)), + }), + ], + }); + const closed = closedGroups.get(host) ?? new Set(); + closedGroups.set(host, closed); + details.open = !closed.has(group); + details.addEventListener("toggle", () => { + if (details.open) closed.delete(group); + else closed.add(group); + }); + return details; +} + +function addBar(ctx: Ctx): HTMLElement { + const add = (key: keyof typeof TEXT, run: () => void) => + createButton({ label: tc(key), variant: "ghost", onClick: run }); + return el("div", { + className: "m01-easycards-add", + children: [ + add("Add_person", () => void addByPick(ctx, "person")), + add("Add_material", () => void addByPick(ctx, "material")), + add("Add_machine", () => void addByPick(ctx, "machine")), + add("Add_logic", () => void addByPick(ctx, "logic")), + add("Add_percent", () => addBlankPercent(ctx)), + ], + }); +} + +/** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */ +export function renderCards( + host: HTMLElement, + doc: EasyDoc, + ans: CalcAnswer | null, + on: { change(doc: EasyDoc): void }, +): void { + const badText = badTexts.get(host) ?? new Map(); + 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)]), + ); +} From 22927e5822e7588c433826021651f258d2ed048e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:46:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore(M01):=20=EC=89=AC=EC=9A=B4=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EA=B2=80=ED=86=A0=20B=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md diff --git a/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md new file mode 100644 index 000000000..66d047bc6 --- /dev/null +++ b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md @@ -0,0 +1,141 @@ +# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만) + +⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜. + +- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인. +- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗. +- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창). + +## 1. 화면 자리 + +| 무엇 | 화면 위치 | 파일:줄 | +| --- | --- | --- | +| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` | +| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` | +| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` | +| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` | +| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` | +| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` | + +- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림. + +## 2. 대표 일 여섯 — 걸음 + +누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수. + +| 일 | 길 | 누름 | 창 | 막힌 곳 | +| --- | --- | --- | --- | --- | +| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 | +| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 | +| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 | +| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 | +| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 | +| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 | + +### ① 새 로직 — 「식으로 새로 만들기」 (실측) + +1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4 +2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2 +3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17 +4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12 +5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3 +6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1) +7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐. +8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함. + +### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측) + +- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」). +- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10). +- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄. +- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸. +- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더. + +### ③ 수량 바꾸기 + +- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글. +- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김. +- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함. + +### ④ 단가 자료(자재 · 노임) 바꾸기 + +- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3. +- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가. +- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐. +- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로. +- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움. + +### ⑤ 줄 지우기 + +- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음. +- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음. +- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음. + +### ⑥ 로직 지우기 · 되돌리기 + +- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소). +- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘. +- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘). +- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음). +- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐. + +## 3. 처음 보는 사람이 모를 낱말 + +- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘 +- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본 +- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기 +- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐 +- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC +- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}` +- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」 +- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표 +- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임) + +## 4. 화면에 안 보이는 규칙 + +1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로. +2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만. +3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`). +4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘. +5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과. +6. 변수 목록은 식 순서가 아니라 가나다 순. +7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦. +8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나. +9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함. +10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음. +11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤. +12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림. +13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함. +14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」. + +## 5. 다섯 기능 — 하려던 것 · 어려운 것 + +- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함. +- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음. +- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」. +- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음. +- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004). + +## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게) + +| # | 흠 | 자리 | 사진 | +| --- | --- | --- | --- | +| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 | +| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — | +| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 | +| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 | +| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — | +| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 | +| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 | + +## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫) + +1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림. +2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임. +3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게. +4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로. +5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀. +6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직. +7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기. +8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로. +9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기.