feat(M01): 로직 화면 — 자동값 채움 · 서버 조각 알약 · 재료 고르기 모달 · 돈 모양 한 벌 (PLAN 3-4 화면)
- 시험 계산: 로직을 열 때 GET /logic/auto 값을 빈 입력에 채우고 「견본」 딱지(사용자가 고치면 빠짐) 뒤 바로 계산 · 텍스트 수식까지. 서버 길이 없으면 안 채움 - 단가산출 상세 수량 칸: 화면 쪽 식 풀이(qtyFragments) 걷고 /text 줄의 조각으로 그림 — 알약 글 = 조각 글 · 깊이 있는 조각은 흐리게 · 묶음별 줄 수가 안 맞으면 식 글자 그대로 - 표 알약 모달: 참조.행·열에 색(행을 못 받으면 열만) - 재료 고르기 줄(요소가 객체): 모달이 객체를 받게 — 잡힌 품목 단가 + 후보 목록 - 돈 모양 한 벌(Logic_Money.ts): 상세·모달·텍스트 수식 소계와 계·시험 계산 결과·조합 미리 보기 정수 · 수량·비율은 그대로 - 서버 파일은 안 만짐 검증 — typecheck 통과 · GF000160 ORCA: 알약 7 서로 다름 · 계 141,882 · 재료 줄 모달 · GF000219·GF000001 회귀 없음. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY2vdBQkHjdih9GgCsp9Fd
This commit is contained in:
@@ -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] ?? "");
|
||||
|
||||
@@ -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<string, string>): 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 {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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<string, ElementBrief | null>;
|
||||
middles: NamedFormula[];
|
||||
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 수량 칸 표 알약 하나가 여는 자료 — 조각 계약의 「표 찾기」 자리 */
|
||||
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
|
||||
export interface TableTarget {
|
||||
title: string;
|
||||
table: string;
|
||||
conds: [string, string][];
|
||||
values: Record<string, string>;
|
||||
/** 찾은 줄 — 줄의 키·원문번호 글, 또는 줄 차례(0부터) */
|
||||
row?: string | number | null;
|
||||
/** 찾은 값 칸 이름 */
|
||||
col?: string | null;
|
||||
}
|
||||
|
||||
function tableView(
|
||||
table: TableRow,
|
||||
conds: [string, string][],
|
||||
values: Record<string, string>,
|
||||
): 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<string, string>, 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<string, ElementBrief | null>;
|
||||
}): 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} ?` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface ElementBrief {
|
||||
단위?: unknown;
|
||||
값?: unknown;
|
||||
값칸?: Record<string, string> | null;
|
||||
/** 재료 후보 줄 — 값 열 다섯(지역·계약종별별 값) */
|
||||
값들?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>> =>
|
||||
request<{ 입력?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`)
|
||||
.then((d) => d.입력 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
export const runCalc = (body: {
|
||||
key: string;
|
||||
inputs: Record<string, unknown>;
|
||||
|
||||
@@ -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<Record<string, string>>();
|
||||
const sampled = new WeakMap<Record<string, string>, Set<string>>();
|
||||
|
||||
/** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */
|
||||
let previewSeq = 0;
|
||||
|
||||
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
const marks = sampled.get(ctx.values) ?? new Set<string>();
|
||||
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<void> => {
|
||||
manualRun = true;
|
||||
const inputs: Record<string, unknown> = {};
|
||||
@@ -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) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user