diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 71765bf8..b688c479 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -8,6 +8,7 @@ import { createInputField, createSelectField, el } from "@ui/ui_template_element import { fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api"; import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api"; import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text"; +import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; const COSTS = ["노무비", "재료비", "경비"] as const; const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { @@ -17,8 +18,8 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { }; const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -/** 원 단위 소수점 0 — 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ -const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); +/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ +const fmt = formatMoney; /** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 03fbe43d..cf558b31 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -14,57 +14,43 @@ import type { LogicRow, NamedFormula, TextAnswer, + TextPiece, } from "./M01_MasterData_UI_Logic_Api"; -import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; +import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; +import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; -import { openMaterialModal, openTableModal } from "./M01_MasterData_UI_LogicLab_Modal"; +import { + condText, + openMaterialModal, + openNoteModal, + openRefModal, + openTableModal, + type PickCond, +} from "./M01_MasterData_UI_LogicLab_Modal"; import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; -/** 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만. 수량·비율은 `formatNumber` 그대로 둠. */ -const formatMoney = (value: unknown): string => - typeof value === "number" - ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) - : formatNumber(value); - -/** 조각 계약(`ref/_분석_알약_상세줄.md` ㉮ 표) — 수량 식 안 찾기(...) 표 호출만 알약, - * 그 밖(숫자·연산기호·중간값·설계입력 이름)은 글자. 서버가 조각 목록을 아직 안 주므로 - * 여기서 임시로 쪼갬(`Logic_Note.ts` 찾기() 정규식과 같은 규칙) — 서버가 붙으면 이 파싱을 걷어냄. */ -const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g; - -function splitFindConds(raw: string): [string, string][] { - return raw - .split(",") - .map((p) => p.trim()) - .filter(Boolean) - .map((p): [string, string] => { - const i = p.indexOf("="); - return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()]; - }); -} - -function qtyFragments(expr: string, values: Record): Fragment[] { - const out: Fragment[] = []; - let last = 0; - for (const m of expr.matchAll(FIND_RE)) { - const at = m.index ?? 0; - if (at > last) out.push({ text: expr.slice(last, at) }); - const table = m[1]; - const label = `${tl("Pill_Table")} ${table}`; - out.push({ - pill: { - label, - onClick: () => openTableModal({ title: label, table, conds: splitFindConds(m[2]), values }), - }, - }); - last = at + m[0].length; - } - if (last < expr.length) out.push({ text: expr.slice(last) }); - return out.length ? out : [{ text: expr }]; +/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ +function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] { + return pieces.map((piece): Fragment => { + const dim = piece.깊이 ? { dim: true } : {}; + if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직") + return { text: piece.글, ...dim }; + const ref = piece.참조; + const open = (): void => { + if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick")); + if (piece.kind === "표") + openTableModal({ title: piece.글, table: ref.키, row: ref.행, col: ref.열 }); + else if (piece.kind === "요소") + openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); + else openNoteModal(piece.글, `${tl("Modal_Logic")} · ${ref.키}`); + }; + return { pill: { label: piece.글, onClick: open }, ...dim }; + }); } export interface DetailContext { @@ -108,6 +94,12 @@ interface Entry { open: () => void; } +/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ +const condOf = (item: HoLine): PickCond | null => + typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null; +const condLabel = (cond: PickCond): string => + [cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · "); + const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey); const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => @@ -204,24 +196,25 @@ function entries(ctx: DetailContext): Entry[] { const ho = ctx.row.호표 ?? []; const out: Entry[] = ho.flatMap((item, i) => { const line = ctx.lines?.[i]; - const brief = ctx.prices[item.요소]; + const cond = condOf(item); + const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소]; const price = line ? line.단가 : brief?.값; const entry: Entry = { group: groupOfHo(item), - name: item.이름 ?? item.요소, + name: item.이름 ?? (cond ? condLabel(cond) : item.요소), spec: item.규격 ?? brief?.규격 ?? "", unit: item.단위 ?? "", qty: item.수량, qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량), - qtyFrag: qtyFragments(item.수량, ctx.values), + qtyFrag: [{ text: item.수량 }], price: price === undefined || price === null ? "" : formatMoney(price), amount: line ? line.금액 : null, extra: false, open: () => openMaterialModal({ - title: item.이름 ?? item.요소, + title: item.이름 ?? (cond ? condLabel(cond) : item.요소), expr: item.수량, - element: item.요소, + ...(cond ? { cond, unit: item.단위 } : { element: item.요소 }), prices: ctx.prices, middles, values: ctx.values, @@ -238,7 +231,7 @@ function entries(ctx: DetailContext): Entry[] { unit: "", qty: extra.식, qtyTag: tl("Extra"), - qtyFrag: qtyFragments(extra.식, ctx.values), + qtyFrag: [{ text: extra.식 }], price: "", amount: line ? line.금액 : null, extra: true, @@ -253,9 +246,25 @@ function entries(ctx: DetailContext): Entry[] { }; out.push(...spread(entry, line)); }); + attachPieces(out, ctx); return out; } +/** 서버 `/text` 줄의 조각을 묶음별로 차례대로 이음 — 줄 수가 서로 안 맞으면(계산 전 로직 부르는 줄 등) + * 그 묶음은 식 글자 그대로 둠(화면에서 식을 다시 풀지 않음) */ +function attachPieces(all: Entry[], ctx: DetailContext): void { + if (!ctx.text?.ok) return; + for (const [cost, group] of Object.entries(OF_COST)) { + const lines = ctx.text.groups.find((g) => g.비목 === cost)?.줄 ?? []; + const mine = all.filter((e) => e.group === group); + if (lines.length !== mine.length) continue; + mine.forEach((e, i) => { + const pieces = lines[i].조각; + if (pieces?.length) e.qtyFrag = toFragments(pieces, ctx); + }); + } +} + const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text }); function lineRow(e: Entry): HTMLElement { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 332cc315..d90f66be 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -7,7 +7,7 @@ import { el } from "@ui/ui_template_elements"; import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Formula.css"; @@ -82,7 +82,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { el("p", { className: "m01lab__subtotal-text m01lab-fx__wide", attrs: { "data-text-sub": String(g.소계) }, - text: `${g.비목} ${tl("Subtotal")} ${formatNumber(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`, + text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`, }), ]); blocks.unshift(heads); @@ -90,7 +90,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { el("p", { className: "m01lab__total m01lab-fx__wide", attrs: { "data-text-sum": String(text.계) }, - text: `${tl("Total")} ${formatNumber(text.계)}`, + text: `${tl("Total")} ${formatMoney(text.계)}`, }), ); host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks })); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 69288127..2eba698a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -9,17 +9,33 @@ * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; -import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; +import { + fetchMaterials, + searchElements, + type ElementBrief, + type NamedFormula, +} from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { explain, loadTable, matchRow, type TableRow } from "./M01_MasterData_UI_Logic_Note"; +import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; +import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note"; import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -/** 원 단위 소수점 0 — 돈 자리만(수량·비율은 `formatNumber` 그대로) */ -const formatMoney = (value: unknown): string => - typeof value === "number" - ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) - : formatNumber(value); +/** 재료 고르기 조건(호표 줄 `요소` 가 글 대신 객체일 때) — 서버 `master_material.PICK_KEYS` 와 같은 칸 */ +export interface PickCond { + 구분?: string; + 상세구분?: string; + 이름?: string; + 규격?: string; + 대표?: string; + [extra: string]: unknown; +} + +/** 서버가 `prices` 열쇠로 쓰는 조건 글 — 「구분|상세구분|이름|규격|대표|지역|계약종별」 */ +export const condText = (cond: PickCond): string => + ["구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별"] + .map((k) => String(cond[k] ?? "")) + .join("|"); export interface ModalTarget { title: string; @@ -27,40 +43,47 @@ export interface ModalTarget { expr: string; /** 호표 줄의 단가 요소 키 — 덧줄은 없음 */ element?: string; + /** 재료 고르기 줄 — `element` 대신 조건 객체 · 잡힌 품목 단가 + 후보 목록을 보임 */ + cond?: PickCond; + /** 호표 줄 단위 — 후보를 같은 단위 줄로만 거름 */ + unit?: string; prices: Record; middles: NamedFormula[]; /** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */ values: Record; } -/** 수량 칸 표 알약 하나가 여는 자료 — 조각 계약의 「표 찾기」 자리 */ +/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ export interface TableTarget { title: string; table: string; - conds: [string, string][]; - values: Record; + /** 찾은 줄 — 줄의 키·원문번호 글, 또는 줄 차례(0부터) */ + row?: string | number | null; + /** 찾은 값 칸 이름 */ + col?: string | null; } -function tableView( - table: TableRow, - conds: [string, string][], - values: Record, -): HTMLElement { - // 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌(줄마다 따로 봄) +function tableView(table: TableRow, target: TableTarget): HTMLElement { + const lines = table.줄 ?? []; + const at = target.row; const hit = - (table.줄 ?? []).find((line) => - matchRow( - { ...table, 줄: [line] }, - conds.filter(([col]) => line[col] !== undefined && line[col] !== null && line[col] !== ""), - { values, middle: {} }, - ), - ) ?? null; + at === undefined || at === null + ? null + : typeof at === "number" + ? (lines[at] ?? null) + : (lines.find((line) => line["키"] === at || line["원문번호"] === at) ?? null); const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})]; - const rows = (table.줄 ?? []).map((line) => + const rows = lines.map((line) => el("tr", { className: hit === line ? "m01lab__hit" : "", attrs: hit === line ? { "data-hit": "1" } : {}, - children: cols.map((c) => el("td", { text: formatNumber(line[c]) })), + children: cols.map((c) => + el("td", { + // 찾은 줄을 알면 그 줄의 칸만 · 줄을 못 받았으면(표 줄에 키가 없는 표) 값 칸 세로줄만 색 + className: c === target.col && (hit === line || !hit) ? "m01lab__hit-cell" : "", + text: formatNumber(line[c]), + }), + ), }), ); const use = table.용도 @@ -77,7 +100,7 @@ function tableView( }), el("p", { className: "m01-logic__muted", - text: hit ? tl("Modal_Hit") : tl("Modal_NoHit"), + text: hit ? tl("Modal_Hit") : target.col ? tl("Modal_ColOnly") : tl("Modal_NoHit"), }), goButton({ kind: "table", key: table.키 }), el("div", { @@ -111,6 +134,7 @@ function goButton(target: MasterJump): HTMLElement { function priceView(ref: string, brief: ElementBrief): HTMLElement { const cols = Object.entries(brief.값칸 ?? {}); + const slots = Object.entries(brief.값들 ?? {}).filter(([, v]) => v !== null && v !== undefined); return el("div", { className: "m01lab__data", attrs: { "data-price": ref }, @@ -128,6 +152,10 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { ? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })] : []), ...cols.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: String(v) })]), + ...slots.flatMap(([k, v]) => [ + el("dt", { text: k }), + el("dd", { className: "m01-logic__money", text: formatMoney(v) }), + ]), ], }), goButton({ kind: "element", group: groupOf(ref), ref: brief.ref ?? ref, file: brief.file }), @@ -162,35 +190,133 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void { dialog.focus(); } -/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) */ -export function openMaterialModal(target: ModalTarget): void { - openDialog(target.title, (body) => { - const base = target.element?.split(".")[0] ?? ""; - const price = target.element - ? (target.prices[target.element] ?? target.prices[base]) - : undefined; - const logicRef = target.element?.startsWith("로직(") ? target.element : ""; - const rateParts: HTMLElement[] = []; - if (price && target.element) { - rateParts.push(priceView(target.element, price)); - } else if (logicRef) { - rateParts.push(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` })); - } else if (target.element) { - rateParts.push( - el("p", { - className: "m01-logic__muted", - text: `${target.element} — ${tl(target.element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`, +const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__muted", text }); + +/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */ +function candidateView( + cond: PickCond, + unit: string, + pickedRef: string, + onPicked: (item: ElementBrief | null) => void, +): HTMLElement { + const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } }); + box.append(muted("…")); + void fetchMaterials({ + sub: String(cond["구분"] ?? ""), + detail: String(cond["상세구분"] ?? ""), + spec: String(cond["규격"] ?? ""), + region: "", + unit, + }) + .then((got) => { + onPicked(got.items.find((it) => it.ref === pickedRef) ?? null); + const rows = got.items.map((it) => + el("tr", { + className: it.ref === pickedRef ? "m01lab__hit" : "", + attrs: it.ref === pickedRef ? { "data-hit": "1" } : {}, + children: [ + el("td", { text: it.이름 ?? it.ref }), + el("td", { text: it.규격 ?? "" }), + el("td", { text: String(it.단위 ?? "") }), + el("td", { className: "m01-logic__money", text: formatMoney(it.값) }), + ], }), ); + const more = got.total > got.items.length ? ` (${got.items.length})` : ""; + box.replaceChildren( + el("strong", { text: `${tl("Modal_Candidates")} ${got.total}${more}` }), + el("div", { + className: "m01-logic__scroll", + children: [ + el("table", { + className: "m01-logic__grid m01lab__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + tl("Col_Name"), + tl("Modal_Spec"), + tl("Col_Unit"), + tl("Modal_Value"), + ].map((h) => el("th", { text: h })), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ], + }), + ); + }) + .catch(() => box.replaceChildren(muted(tl("Pill_NoPick")))); + return box; +} + +/** 고르기 줄에서 시험 계산이 잡은 품목 키 — 대표가 `{입력이름}` 이면 넣은 값 · 글자 키면 그대로 · 없으면 서버 미리보기 줄 */ +function pickedOf(cond: PickCond, values: Record, fallback: string): string { + const rep = String(cond["대표"] ?? ""); + const named = /^\{(.+)\}$/.exec(rep); + return (named ? (values[named[1]] ?? "") : rep) || fallback; +} + +/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) · + * 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */ +export function openMaterialModal(target: ModalTarget): void { + openDialog(target.title, (body) => { + const cond = target.cond; + const element = target.element ?? ""; + const base = element.split(".")[0]; + // 고르기 줄 = 서버가 조건 글을 열쇠로 준 줄(대표가 안 정해졌으면 없음) + const known = cond + ? target.prices[condText(cond)] + : element + ? (target.prices[element] ?? target.prices[base]) + : undefined; + const priceRef = cond ? pickedOf(cond, target.values, known?.ref ?? "") : element; + const logicRef = element.startsWith("로직(") ? element : ""; + const sum = el("p", { className: "m01lab__sum" }); + const setSum = (price: ElementBrief | null | undefined): void => { + const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.값)} × ` : ""; + sum.textContent = `${target.title} = ${unit}${explain(target.expr)}`; + }; + setSum(known); + const rate = el("div", { className: "m01lab__modal-body" }); + if (known && priceRef) { + rate.append(priceView(priceRef, known)); + } else if (logicRef) { + rate.append(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` })); + } else if (cond && priceRef) { + rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴 + } else if (cond) { + rate.append(muted(tl("Pill_NoPick"))); + } else if (element) { + rate.append( + muted(`${element} — ${tl(element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`), + ); } - const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.값)} × ` : ""; body.replaceChildren( - ...rateParts, - el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }), - el("p", { - className: "m01lab__sum", - text: `${target.title} = ${unit}${explain(target.expr)}`, - }), + rate, + ...(cond + ? [ + candidateView(cond, target.unit ?? "", priceRef, (item) => { + if (known || !priceRef) return; + const show = (brief: ElementBrief | null): void => { + rate.replaceChildren( + brief ? priceView(priceRef, brief) : muted(`${priceRef} — ${tl("Pill_NoValue")}`), + ); + setSum(brief); + }; + if (item) return show(item); + void searchElements("재료", priceRef) + .then((got) => show(got.items.find((it) => it.ref === priceRef) ?? null)) + .catch(() => show(null)); + }), + ] + : []), + muted(tl("Modal_Sum")), + sum, el("details", { className: "m01lab__raw", children: [ @@ -202,6 +328,31 @@ export function openMaterialModal(target: ModalTarget): void { }); } +/** 수량 칸 요소 알약을 눌렀을 때 — 그 요소의 단가 자료(이미 받은 `prices` 에 없으면 키로 찾아 옴) */ +export function openRefModal(target: { + title: string; + ref: string; + prices: Record; +}): void { + openDialog(target.title, (body) => { + const show = (brief: ElementBrief | null): void => + body.replaceChildren( + brief ? priceView(target.ref, brief) : muted(`${target.ref} — ${tl("Pill_NoValue")}`), + ); + const known = target.prices[target.ref]; + if (known) return show(known); + body.replaceChildren(muted("…")); + void searchElements(groupOf(target.ref), target.ref) + .then((got) => show(got.items.find((it) => it.ref === target.ref) ?? null)) + .catch(() => show(null)); + }); +} + +/** 알약이 가리킬 자료를 못 찾을 때(참조 없음) · 다른 로직을 부르는 알약 — 한 마디만 */ +export function openNoteModal(title: string, text: string): void { + openDialog(title, (body) => body.replaceChildren(el("p", { text }))); +} + /** 수량 칸 표 알약을 눌렀을 때 — 그 표만 보임(걸린 줄 강조) */ export function openTableModal(target: TableTarget): void { openDialog(target.title, (body) => { @@ -209,7 +360,7 @@ export function openTableModal(target: TableTarget): void { void loadTable(target.table).then((table) => { body.replaceChildren( table - ? tableView(table, target.conds, target.values) + ? tableView(table, target) : el("p", { className: "m01-logic__bad", text: `${target.table} ?` }), ); }); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.css index 91d90601..657f6bbc 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.css @@ -33,3 +33,14 @@ .m01lab__pill-stage { min-width: 0; } + +/* 계산 안 된 만약() 가지 조각 — 흐리게 */ +.m01lab__qty-dim { + opacity: 0.45; +} + +/* 표 모달 — 찾은 칸(참조.열) 한 번 더 진하게 */ +td.m01lab__hit-cell, +.m01lab__hit td.m01lab__hit-cell { + background: color-mix(in srgb, orange 65%, transparent); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts index 4e28b0ba..31904049 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts @@ -28,17 +28,20 @@ export function pillButton(p: Pill): HTMLButtonElement { return b; } -/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) */ -export type Fragment = { pill: Pill } | { text: string }; +/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) · `dim` = 계산 안 된 갈래(흐리게) */ +export type Fragment = { pill: Pill; dim?: boolean } | { text: string; dim?: boolean }; /** 조각을 이어 그림 — 칸 폭을 넘으면 자동으로 줄바꿈(`flex-wrap`) */ export function fragmentRow(fragments: Fragment[]): HTMLElement { return el("span", { className: "m01lab__qty-frags", - children: fragments.map((f) => - "pill" in f - ? pillButton(f.pill) - : el("span", { className: "m01lab__qty-text", text: f.text }), - ), + children: fragments.map((f) => { + const one = + "pill" in f + ? pillButton(f.pill) + : el("span", { className: "m01lab__qty-text", text: f.text }); + if (f.dim) one.classList.add("m01lab__qty-dim"); + return one; + }), }); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index eff054cb..c4d691ec 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -64,9 +64,17 @@ const TEXT = { Modal_Value: ["값", "Value"], Modal_Source: ["출처", "Source"], Modal_Spec: ["규격", "Spec"], + Modal_Candidates: [ + "후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)", + "Candidates (yellow = used in the trial)", + ], Modal_Logic: ["다른 로직을 부름", "Calls another logic"], Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"], Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"], + Modal_ColOnly: [ + "진한 칸 = 찾은 값 칸 — 서버가 줄 자리는 안 줌", + "Dark cells = the value column found; the server gave no row", + ], Modal_NoHit: [ "지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음", "No row matches the current values — change them, or this table is on an unused branch", diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index fc50d374..5d478add 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -66,6 +66,8 @@ export interface ElementBrief { 단위?: unknown; 값?: unknown; 값칸?: Record | null; + /** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */ + 값들?: Record; } export interface LogicOne { @@ -97,11 +99,20 @@ export type CalcAnswer = } | { ok: false; reason: string }; +/** `/text` 줄의 조각 — 알약 글 = 조각 글 · `깊이` 있으면 계산 안 된 만약() 가지(흐리게) */ +export interface TextPiece { + kind: "요소" | "표" | "로직" | "값" | "기호" | "글"; + 글: string; + 참조?: { 테이블?: string; 키: string; 행?: string | number | null; 열?: string | null }; + 깊이?: number; +} export interface TextLine { 이름: string; 글: string; /** 같은 줄을 변수 이름으로 적은 식 — 왼쪽 칸 */ 이름글?: string; + /** 수량 식을 쪼갠 조각 — 알약(요소·표·로직) · 글자(값·기호·글) */ + 조각?: TextPiece[]; /** 줄 번호(답 전체에서 하나씩) — 두 칸 줄 맞춤 · 밝히기 */ 짝?: number; 금액: number; @@ -192,9 +203,17 @@ export const fetchMaterials = (cond: { detail?: string; spec?: string; region?: string; + /** 호표 줄 단위 — 주면 그 단위 줄만 */ + unit?: string; }): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> => request(`/materials?${query({ ...cond, limit: "100" })}`); +/** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */ +export const fetchAuto = (key: string): Promise> => + request<{ 입력?: Record }>(`/logic/auto?${query({ key })}`) + .then((d) => d.입력 ?? {}) + .catch(() => ({})); + export const runCalc = (body: { key: string; inputs: Record; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 821c76bd..484592d7 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -6,6 +6,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { + fetchAuto, runCalc, runText, searchElements, @@ -15,6 +16,7 @@ import { type LogicRow, } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { tx } from "./M01_MasterData_UI_Logic_Text"; /** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */ @@ -55,10 +57,19 @@ export interface CalcContext { const SUMS = ["노무비", "재료비", "경비", "계"]; +/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) · 견본으로 채운 입력 이름 — 사용자가 고치면 빠짐 */ +const autoAsked = new WeakSet>(); +const sampled = new WeakMap, Set>(); + /** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */ let previewSeq = 0; export function buildCalc(host: HTMLElement, ctx: CalcContext): void { + const marks = sampled.get(ctx.values) ?? new Set(); + const edited = (name: string, tag?: HTMLElement): void => { + marks.delete(name); + tag?.remove(); + }; // 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 — // 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂 const optionText = (o: string | number): string => @@ -67,12 +78,18 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { const name = spec.이름; let control: HTMLElement; const picks = spec.고르기; + const tag = marks.has(name) + ? el("span", { className: "m01-logic__tag", text: tx("Calc_Sample") }) + : undefined; if (picks?.length) { const pick = createSelectField({ options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })), value: ctx.values[name] ?? "", compact: true, - onChange: (v) => (ctx.values[name] = v), + onChange: (v) => { + ctx.values[name] = v; + edited(name, tag); + }, }); control = pick.root; const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o)); @@ -91,7 +108,10 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { }); if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; box.value = ctx.values[name] ?? ""; - box.addEventListener("input", () => (ctx.values[name] = box.value)); + box.addEventListener("input", () => { + ctx.values[name] = box.value; + edited(name, tag); + }); control = box; } const label = spec.단위 ? `${name} (${spec.단위})` : name; @@ -99,10 +119,11 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { const wide = label.length > 10; return el("label", { className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`, - children: [el("span", { text: label }), control], + children: [el("span", { text: label }), ...(tag ? [tag] : []), control], }); }); const out = el("div", { className: "m01-logic__calc-out" }); + const mySeqAtOpen = ctx.onText ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번 let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음 if (ctx.onText) { const mySeq = ++previewSeq; @@ -118,6 +139,19 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { ctx.onText?.(answer); }); } + if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) { + autoAsked.add(ctx.values); + void fetchAuto(ctx.savedKey).then((auto) => { + const names = (ctx.row.입력 ?? []) + .map((spec) => spec.이름) + .filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim()); + if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림 + for (const n of names) ctx.values[n] = String(auto[n]); + sampled.set(ctx.values, new Set([...marks, ...names])); + buildCalc(host, ctx); // 채운 값·견본 딱지를 새로 그리고 바로 계산(결과 · 텍스트 수식까지) + host.querySelector("button")?.click(); + }); + } const run = async (): Promise => { manualRun = true; const inputs: Record = {}; @@ -139,7 +173,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { const answer = await runCalc(body); if (ctx.onText) ctx.onText(await runText(body)); ctx.onLines(answer.ok ? (answer.lines ?? null) : null); - out.replaceChildren(...answerView(answer, draft)); + out.replaceChildren(...answerView(answer, draft, (ctx.row.결과단위 ?? "").startsWith("원"))); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } @@ -154,7 +188,8 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { ); } -function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] { +/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */ +function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : []; if (!answer.ok) { return [ @@ -172,7 +207,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] { children: [ el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }), el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }), - el("td", { className: "m01-logic__money", text: formatNumber(line.금액) }), + el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }), ], }), ); @@ -200,7 +235,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] { className: "m01-logic__sums", children: SUMS.flatMap((k) => [ el("dt", { text: k === "계" ? tx("Calc_Sum") : k }), - el("dd", { className: "m01-logic__money", text: formatNumber(answer.sums?.[k] ?? 0) }), + el("dd", { className: "m01-logic__money", text: formatMoney(answer.sums?.[k] ?? 0) }), ]), }), ); @@ -211,7 +246,7 @@ function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] { className: "m01-logic__sums", children: [ el("dt", { text: tx("Calc_Result") }), - el("dd", { text: formatNumber(answer.result) }), + el("dd", { text: money ? formatMoney(answer.result) : formatNumber(answer.result) }), ], }), ); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Money.ts b/M01_MasterData/M01_MasterData_UI_Logic_Money.ts new file mode 100644 index 00000000..ca28a43f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Money.ts @@ -0,0 +1,11 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Money.ts + * 돈 모양 한 벌 — 원 단위 정수(반올림). 단가·금액·소계·계 자리만 씀 · 수량·비율은 `formatNumber` 그대로 + * ========================================================================== */ + +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; + +export const formatMoney = (value: unknown): string => + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) + : formatNumber(value); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 95c7137a..8ef71ee7 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -60,6 +60,7 @@ const TEXT = { Calc_Title: ["시험 계산", "Test calculation"], Calc_Run: ["계산", "Calculate"], Calc_NoInputs: ["받을 값 없음", "No inputs"], + Calc_Sample: ["견본", "Sample"], Calc_Draft: ["고친 줄로 계산(저장 전)", "Using unsaved edits"], Calc_Stopped: ["멈춤", "Stopped"], Calc_Result: ["결과", "Result"],