feat(M01): 로직 개선 시험 상세 화면을 컨테이너 넷으로 · 호표를 인력·자재·경비 묶음과 소계로 · 줄을 누르면 쓰는 자료(표·단가) 모달
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
@@ -19,23 +19,20 @@ import {
|
||||
import {
|
||||
ApiError,
|
||||
fetchLogic,
|
||||
fetchLogicFiles,
|
||||
fetchLogics,
|
||||
fetchLogicSubs,
|
||||
saveFiles,
|
||||
type CalcLine,
|
||||
type ElementBrief,
|
||||
type LogicFile,
|
||||
type LogicRow,
|
||||
type LogicSummary,
|
||||
type SaveFile,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
|
||||
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import type { SideHandle } from "./M01_MasterData_UI_Side";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail";
|
||||
import "./M01_MasterData_UI_Logic_Style.css";
|
||||
|
||||
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
|
||||
@@ -80,12 +77,11 @@ export async function mountM01LogicLab(
|
||||
openKey?: string,
|
||||
): Promise<LogicHandle> {
|
||||
let drafts = loadDrafts();
|
||||
let files: LogicFile[] = [];
|
||||
let opened: Opened | null = null;
|
||||
let calcInputs = "";
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
|
||||
const calc = el("aside", { className: "m01-logic__calc" });
|
||||
const calc = el("div", { className: "m01lab__calc" });
|
||||
const filterHost = el("div");
|
||||
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
|
||||
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
|
||||
@@ -168,7 +164,6 @@ export async function mountM01LogicLab(
|
||||
list.root.hidden = !listing || !loaded;
|
||||
waiting.hidden = loaded;
|
||||
editor.hidden = back.hidden = listing;
|
||||
calc.hidden = listing;
|
||||
if (!opened) return;
|
||||
if (!opened.row) {
|
||||
editor.replaceChildren(
|
||||
@@ -177,29 +172,18 @@ export async function mountM01LogicLab(
|
||||
return;
|
||||
}
|
||||
const current = opened;
|
||||
buildEditor(editor, {
|
||||
buildLabDetail(editor, {
|
||||
row: current.row as LogicRow,
|
||||
file: current.file,
|
||||
isNew: current.origKey === null,
|
||||
files: files.map((f) => f.file),
|
||||
reasons: current.reasons,
|
||||
prices: current.prices,
|
||||
lines: current.lines,
|
||||
values: current.values,
|
||||
onChange: touch,
|
||||
onFile: (file) => {
|
||||
const f = files.find((x) => x.file === file);
|
||||
if (!f) return;
|
||||
Object.assign(current, { file: f.file, sub: subOf(f), version: f.version });
|
||||
touch();
|
||||
},
|
||||
onPick: openPicker,
|
||||
calcHost: calc,
|
||||
});
|
||||
};
|
||||
|
||||
/** 로직 파일 → 구분(원문 + 부문) — 「공통 03장 토공사」 의 앞말이 부문 */
|
||||
const subOf = (f: LogicFile): string =>
|
||||
f.book === "건설품셈" ? `${f.book} ${f.chapter.split(" ")[0]}` : String(f.book);
|
||||
|
||||
const show = (next: Opened | null): void => {
|
||||
opened = next;
|
||||
errors.hidden = true;
|
||||
@@ -236,12 +220,7 @@ export async function mountM01LogicLab(
|
||||
let allLogics: LogicSummary[] = [];
|
||||
|
||||
const reload = async (): Promise<void> => {
|
||||
const [logics, logicFiles, subs] = await Promise.all([
|
||||
fetchLogics(),
|
||||
fetchLogicFiles(),
|
||||
fetchLogicSubs(),
|
||||
]);
|
||||
files = logicFiles;
|
||||
const [logics, subs] = await Promise.all([fetchLogics(), fetchLogicSubs()]);
|
||||
allLogics = logics;
|
||||
list.setItems(logics);
|
||||
// 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만
|
||||
@@ -263,43 +242,6 @@ export async function mountM01LogicLab(
|
||||
persist();
|
||||
};
|
||||
|
||||
/** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */
|
||||
const onDraftNew = (): void => {
|
||||
const f = files.find((x) => x.file === opened?.file) ?? files[0];
|
||||
if (!f) return;
|
||||
const id = `new:${Date.now()}`;
|
||||
// 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌
|
||||
const row: LogicRow = {
|
||||
키: "",
|
||||
원문번호: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
|
||||
이름: "",
|
||||
결과단위: "원/",
|
||||
출처: "",
|
||||
소유: "공용",
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
끝수: null,
|
||||
};
|
||||
const next: Opened = {
|
||||
id,
|
||||
sub: subOf(f),
|
||||
file: f.file,
|
||||
version: f.version,
|
||||
origKey: null,
|
||||
row,
|
||||
original: null,
|
||||
prices: {},
|
||||
reasons: [],
|
||||
lines: null,
|
||||
values: {},
|
||||
};
|
||||
drafts[id] = pick(next, clone(row));
|
||||
persist();
|
||||
show(next);
|
||||
};
|
||||
|
||||
const onDelete = async (): Promise<void> => {
|
||||
if (!opened?.row) return;
|
||||
if (
|
||||
@@ -389,7 +331,6 @@ export async function mountM01LogicLab(
|
||||
back,
|
||||
el("h2", { text: tx("Title") }),
|
||||
pending,
|
||||
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onDraftNew }),
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
discardButton,
|
||||
saveButton,
|
||||
@@ -403,7 +344,6 @@ export async function mountM01LogicLab(
|
||||
className: "m01-logic__main",
|
||||
children: [bar, errors, waiting, list.root, editor],
|
||||
}),
|
||||
calc,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Detail.ts
|
||||
* 「로직 개선 시험」 상세 화면 — 컨테이너 넷을 위에서 아래로
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 일위대가 호표(인력·자재·경비·그 밖 묶음 + 소계)
|
||||
* ③ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) ④ 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
|
||||
* 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
CalcLine,
|
||||
ElementBrief,
|
||||
HoLine,
|
||||
LogicRow,
|
||||
NamedFormula,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
|
||||
export interface DetailContext {
|
||||
row: LogicRow;
|
||||
file: string;
|
||||
reasons: string[];
|
||||
prices: Record<string, ElementBrief | null>;
|
||||
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
|
||||
lines: CalcLine[] | null;
|
||||
values: Record<string, string>;
|
||||
onChange: () => void;
|
||||
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
|
||||
calcHost: HTMLElement;
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
const GROUPS: Group[] = ["labor", "material", "cost", "other"];
|
||||
const OF_KIND: Record<string, Group> = { 인력: "labor", 재료: "material", 기계: "cost" };
|
||||
const OF_COST: Record<string, Group> = { 노무비: "labor", 재료비: "material", 경비: "cost" };
|
||||
|
||||
/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */
|
||||
const groupOfHo = (item: HoLine): Group =>
|
||||
OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other";
|
||||
|
||||
interface Entry {
|
||||
group: Group;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
qty: string;
|
||||
qtyTag: string;
|
||||
price: string;
|
||||
amount: number | null;
|
||||
/** 덧줄이면 true */
|
||||
extra: boolean;
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey);
|
||||
|
||||
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01-logic__section m01lab__box",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
el("h3", { text: title }),
|
||||
...(hint ? [el("span", { className: "m01-logic__muted", text: hint })] : []),
|
||||
],
|
||||
}),
|
||||
...body,
|
||||
],
|
||||
});
|
||||
|
||||
function infoBox(ctx: DetailContext): HTMLElement {
|
||||
const row = ctx.row;
|
||||
const cell = (label: string, value: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01lab__cell",
|
||||
children: [
|
||||
el("span", { className: "m01-logic__muted", text: label }),
|
||||
el("span", { text: value || "—" }),
|
||||
],
|
||||
});
|
||||
const note = el("textarea", { className: "m01-logic__input m01-logic__note" });
|
||||
note.value = row.비고 ?? "";
|
||||
note.addEventListener("input", () => {
|
||||
if (note.value.trim()) row.비고 = note.value;
|
||||
else delete row.비고;
|
||||
ctx.onChange();
|
||||
});
|
||||
const reasons = ctx.reasons.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-logic__reasons",
|
||||
children: [
|
||||
el("strong", { text: tx("Head_Blocked") }),
|
||||
...ctx.reasons.map((r) => el("div", { text: r })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return section(tl("Info_Title"), [
|
||||
el("p", { className: "m01-logic__muted", text: tl("Info_ReadOnly") }),
|
||||
...reasons,
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
cell(tx("Head_File"), ctx.file),
|
||||
cell(tx("Head_Key"), row.키),
|
||||
cell(tx("Head_Number"), row.원문번호),
|
||||
cell(tx("Head_Name"), row.이름),
|
||||
cell(tx("Head_Unit"), row.결과단위),
|
||||
cell(tx("Head_Source"), row.출처),
|
||||
cell(tx("Head_Owner"), row.소유 ?? ""),
|
||||
el("label", {
|
||||
className: "m01lab__cell m01lab__cell--wide",
|
||||
children: [el("span", { className: "m01-logic__muted", text: tx("Head_Note") }), note],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function entries(ctx: DetailContext): Entry[] {
|
||||
const middles: NamedFormula[] = ctx.row.중간 ?? [];
|
||||
const ho = ctx.row.호표 ?? [];
|
||||
const out: Entry[] = ho.map((item, i) => {
|
||||
const line = ctx.lines?.[i];
|
||||
const brief = ctx.prices[item.요소];
|
||||
const price = line ? line.단가 : brief?.값;
|
||||
return {
|
||||
group: groupOfHo(item),
|
||||
name: item.이름 ?? item.요소,
|
||||
spec: item.규격 ?? brief?.규격 ?? "",
|
||||
unit: item.단위 ?? "",
|
||||
qty: item.수량,
|
||||
qtyTag: qtyKind(item.수량),
|
||||
price: price === undefined || price === null ? "" : formatNumber(price),
|
||||
amount: line ? line.금액 : null,
|
||||
extra: false,
|
||||
open: () =>
|
||||
openMaterialModal({
|
||||
title: item.이름 ?? item.요소,
|
||||
expr: item.수량,
|
||||
element: item.요소,
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
}),
|
||||
};
|
||||
});
|
||||
(ctx.row.덧줄 ?? []).forEach((extra, i) => {
|
||||
const line = ctx.lines?.[ho.length + i];
|
||||
out.push({
|
||||
group: OF_COST[extra.비목 ?? ""] ?? "other",
|
||||
name: extra.이름,
|
||||
spec: "",
|
||||
unit: "",
|
||||
qty: extra.식,
|
||||
qtyTag: tl("Extra"),
|
||||
price: "",
|
||||
amount: line ? line.금액 : null,
|
||||
extra: true,
|
||||
open: () =>
|
||||
openMaterialModal({
|
||||
title: extra.이름,
|
||||
expr: extra.식,
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
}),
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
|
||||
function lineRow(e: Entry): HTMLElement {
|
||||
const tr = el("tr", {
|
||||
className: `m01lab__line${e.extra ? " m01lab__line--extra" : ""}`,
|
||||
attrs: { tabindex: "0", "data-line": e.name },
|
||||
children: [
|
||||
el("td", {
|
||||
children: [
|
||||
el("div", { text: e.name }),
|
||||
...(e.spec ? [el("div", { className: "m01-logic__muted", text: e.spec })] : []),
|
||||
],
|
||||
}),
|
||||
cellOf(e.unit),
|
||||
el("td", {
|
||||
children: [
|
||||
el("span", { className: "m01-logic__tag", text: e.qtyTag }),
|
||||
el("span", { className: "m01lab__qty", text: ` ${e.qty}` }),
|
||||
],
|
||||
}),
|
||||
cellOf(e.price, "m01-logic__money"),
|
||||
cellOf(e.amount === null ? "" : formatNumber(e.amount), "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", e.open);
|
||||
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open());
|
||||
return tr;
|
||||
}
|
||||
|
||||
function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLElement[] {
|
||||
const sub = mine.reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
const empty = el("tr", {
|
||||
children: [
|
||||
el("td", {
|
||||
attrs: { colspan: "5" },
|
||||
className: "m01-logic__muted",
|
||||
text: tl("Empty_Group"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const subtotal = el("tr", {
|
||||
className: "m01lab__subtotal",
|
||||
attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" },
|
||||
children: [
|
||||
el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
|
||||
cellOf(calculated ? formatNumber(sub) : "—", "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
const headers = [
|
||||
tl("Col_Name"),
|
||||
tl("Col_Unit"),
|
||||
tl("Col_Qty"),
|
||||
tl("Col_Price"),
|
||||
tl("Col_Amount"),
|
||||
];
|
||||
return [
|
||||
el("h4", { className: "m01lab__group", text: groupName(group) }),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-logic__grid m01lab__ho",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })],
|
||||
}),
|
||||
el("tbody", { children: [...(mine.length ? mine.map(lineRow) : [empty]), subtotal] }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function hoBox(ctx: DetailContext): HTMLElement {
|
||||
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
||||
if (!money) {
|
||||
return section(tl("Ho_Title"), [
|
||||
el("p", { className: "m01-logic__muted", text: tl("Not_Money") }),
|
||||
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
||||
]);
|
||||
}
|
||||
const all = entries(ctx);
|
||||
const calculated = ctx.lines !== null;
|
||||
const blocks = GROUPS.flatMap((group) => {
|
||||
const mine = all.filter((e) => e.group === group);
|
||||
return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만
|
||||
});
|
||||
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
blocks.push(
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": calculated ? String(total) : "" },
|
||||
text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`,
|
||||
}),
|
||||
);
|
||||
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
||||
return section(tl("Ho_Title"), blocks, hint);
|
||||
}
|
||||
|
||||
/** 상세 화면 넷을 `host` 에 쌓음 */
|
||||
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
const formula = el("div", { className: "m01lab__formula" });
|
||||
buildFormula(formula, { row: ctx.row, lines: ctx.lines });
|
||||
host.replaceChildren(
|
||||
infoBox(ctx),
|
||||
hoBox(ctx),
|
||||
section(tl("Formula_Title"), [formula]),
|
||||
section(tl("Calc_Title"), [ctx.calcHost]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Formula.ts
|
||||
* 「텍스트 수식」 컨테이너 자리 — 비목 묶음별 `이름 : 단가 * 수량식 = 금액` 줄은 여기서 채움(PLAN 3-2)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { CalcLine, LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export interface FormulaContext {
|
||||
row: LogicRow;
|
||||
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
|
||||
lines: CalcLine[] | null;
|
||||
}
|
||||
|
||||
/** 자리만 — 줄을 채우는 일감이 `host` 안에 그림 */
|
||||
export function buildFormula(host: HTMLElement, _ctx: FormulaContext): void {
|
||||
host.replaceChildren(el("p", { className: "m01-logic__muted", text: tl("Formula_Slot") }));
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Modal.ts
|
||||
* 호표 줄 자료 모달 — 그 줄이 쓰는 자료를 보임
|
||||
* 표(소요량·계수)는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
|
||||
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import {
|
||||
loadTable,
|
||||
matchRow,
|
||||
relatedFinds,
|
||||
type RelatedFind,
|
||||
type TableRow,
|
||||
} from "./M01_MasterData_UI_Logic_Note";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export interface ModalTarget {
|
||||
title: string;
|
||||
/** 수량 식(호표 줄) · 덧줄은 그 식 */
|
||||
expr: string;
|
||||
/** 호표 줄의 단가 요소 키 — 덧줄은 없음 */
|
||||
element?: string;
|
||||
prices: Record<string, ElementBrief | null>;
|
||||
middles: NamedFormula[];
|
||||
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
function tableView(
|
||||
find: RelatedFind,
|
||||
table: TableRow,
|
||||
values: Record<string, string>,
|
||||
): HTMLElement {
|
||||
const hit = matchRow(table, find.find.conds, { values, middle: {} });
|
||||
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
|
||||
const rows = (table.줄 ?? []).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]) })),
|
||||
}),
|
||||
);
|
||||
const use = table.용도
|
||||
? [table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
return el("div", {
|
||||
className: "m01lab__data",
|
||||
attrs: { "data-table": table.키 },
|
||||
children: [
|
||||
el("strong", { text: `${table.원문번호 ?? ""} ${table.이름 ?? table.키} · ${table.키}` }),
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: [`${tl("Modal_Source")} ${table.출처 ?? ""}`, use].filter(Boolean).join(" · "),
|
||||
}),
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${find.source} → ${find.find.col} · ${hit ? tl("Modal_Hit") : tl("Modal_NoHit")}`,
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-logic__grid m01lab__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
const cols = Object.entries(brief.값칸 ?? {});
|
||||
return el("div", {
|
||||
className: "m01lab__data",
|
||||
attrs: { "data-price": ref },
|
||||
children: [
|
||||
el("strong", { text: `${brief.이름 ?? ref} · ${ref}` }),
|
||||
el("dl", {
|
||||
className: "m01-logic__sums",
|
||||
children: [
|
||||
...(brief.규격
|
||||
? [el("dt", { text: tl("Modal_Spec") }), el("dd", { text: brief.규격 })]
|
||||
: []),
|
||||
el("dt", { text: tl("Modal_Value") }),
|
||||
el("dd", { className: "m01-logic__money", text: formatNumber(brief.값) }),
|
||||
...(brief.file
|
||||
? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })]
|
||||
: []),
|
||||
...cols.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: String(v) })]),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 모달을 엶 */
|
||||
export function openMaterialModal(target: ModalTarget): void {
|
||||
const body = el("div", { className: "m01lab__modal-body" });
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01lab__modal",
|
||||
attrs: { role: "dialog", "aria-label": target.title },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01lab__modal-top",
|
||||
children: [
|
||||
el("h3", { text: target.title }),
|
||||
createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
|
||||
const parts: HTMLElement[] = [
|
||||
el("p", { className: "m01-logic__muted", text: tl("Modal_Qty") }),
|
||||
el("pre", { className: "m01lab__expr", text: target.expr }),
|
||||
];
|
||||
const finds = relatedFinds(target.expr, target.middles);
|
||||
const price = target.element ? target.prices[target.element] : undefined;
|
||||
if (target.element?.startsWith("로직(")) {
|
||||
parts.push(el("p", { text: `${tl("Modal_Logic")} · ${target.element}` }));
|
||||
} else if (price) {
|
||||
parts.push(
|
||||
el("p", { className: "m01-logic__muted", text: tl("Modal_Price") }),
|
||||
priceView(target.element as string, price),
|
||||
);
|
||||
}
|
||||
const tables = el("div", { className: "m01lab__tables" });
|
||||
parts.push(tables);
|
||||
if (!finds.length && !price && !target.element?.startsWith("로직(")) {
|
||||
parts.push(el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") }));
|
||||
}
|
||||
body.replaceChildren(...parts);
|
||||
document.body.append(backdrop);
|
||||
dialog.tabIndex = -1;
|
||||
dialog.focus();
|
||||
|
||||
void Promise.all(finds.map(async (f) => ({ f, table: await loadTable(f.find.table) }))).then(
|
||||
(got) =>
|
||||
tables.replaceChildren(
|
||||
...got.map(({ f, table }) =>
|
||||
table
|
||||
? tableView(f, table, target.values)
|
||||
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* M01 로직 개선 시험 — 상세 화면 컨테이너 넷 · 호표 묶음 · 자료 모달 */
|
||||
.m01lab__box {
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01lab__info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01lab__cell--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.m01lab__group {
|
||||
margin: var(--spacing-8) 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(2) {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(4),
|
||||
.m01lab__ho th:nth-child(5) {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.m01lab__line {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01lab__line:hover,
|
||||
.m01lab__line:focus {
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
.m01lab__line--extra {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m01lab__qty,
|
||||
.m01lab__expr {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.m01lab__subtotal td {
|
||||
font-weight: 600;
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
.m01lab__total {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01lab__modal {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01lab__modal-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab__modal-body,
|
||||
.m01lab__tables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab__hit td {
|
||||
background: color-mix(in srgb, gold 45%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01lab__calc > h3 {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Text.ts
|
||||
* 「로직 개선 시험」 컨테이너 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Info_Title: ["기본정보", "Basic info"],
|
||||
Info_ReadOnly: [
|
||||
"기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음",
|
||||
"Base logic follows the source book; only the note can be edited",
|
||||
],
|
||||
Ho_Title: ["일위대가 호표", "Unit-cost table"],
|
||||
Formula_Title: ["텍스트 수식", "Text formula"],
|
||||
Formula_Slot: ["(준비 중)", "(coming)"],
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
G_material: ["자재", "Material"],
|
||||
G_cost: ["경비", "Expense"],
|
||||
G_other: ["그 밖", "Other"],
|
||||
Subtotal: ["소계", "Subtotal"],
|
||||
Total: ["계", "Total"],
|
||||
Extra: ["덧줄", "Extra"],
|
||||
Col_Name: ["이름", "Name"],
|
||||
Col_Unit: ["단위", "Unit"],
|
||||
Col_Qty: ["수량", "Quantity"],
|
||||
Col_Price: ["단가", "Price"],
|
||||
Col_Amount: ["금액", "Amount"],
|
||||
Empty_Group: ["줄 없음", "No lines"],
|
||||
Not_Money: ["호표가 없는 로직 — 결과 식", "No unit-cost lines — result formula"],
|
||||
Calc_First: [
|
||||
"금액은 아래 「시험 계산」 을 누르면 채워짐",
|
||||
"Amounts fill after the trial calculation",
|
||||
],
|
||||
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
|
||||
Modal_Close: ["닫기", "Close"],
|
||||
Modal_Qty: ["수량 식", "Quantity formula"],
|
||||
Modal_Price: ["단가 자료", "Price source"],
|
||||
Modal_Value: ["값", "Value"],
|
||||
Modal_Source: ["출처", "Source"],
|
||||
Modal_Spec: ["규격", "Spec"],
|
||||
Modal_Logic: ["다른 로직을 부름", "Calls another logic"],
|
||||
Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"],
|
||||
Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"],
|
||||
Modal_NoHit: [
|
||||
"걸린 줄을 아직 못 맞힘 — 「시험 계산」 에 값을 넣고 다시 누르세요",
|
||||
"No matching row yet — enter trial values and reopen",
|
||||
],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type LabTextKey = keyof typeof TEXT;
|
||||
|
||||
export function tl(key: LabTextKey): string {
|
||||
const entry = TEXT[key];
|
||||
return entry[currentLanguageIndex as 0 | 1] ?? entry[0];
|
||||
}
|
||||
Reference in New Issue
Block a user