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:
@@ -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} ?` }),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user