feat(m02-df): 단가산출 흐름도 0걸음 — CostFlow 를 DataFlow 로 옮겨 이름 바꿈
- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로 - _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함 - 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로) - 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Table.ts
|
||||
* 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나)
|
||||
* `M02_DF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import type { CfGroup, CfLogic } from "./M02_DF_Model";
|
||||
import { setTable } from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
Book: ["적용 품셈", "Standard"],
|
||||
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoSection: ["찾는 절 없음", "No section"],
|
||||
SecLine: ["표 {t} 개", "{t} tables"],
|
||||
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
|
||||
NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"],
|
||||
TableLine: [
|
||||
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
|
||||
"{b} · {c} conditions · {v} results · {n} rows",
|
||||
],
|
||||
Chosen: ["고른 표", "Chosen table"],
|
||||
Whole: [
|
||||
"표 통째 — 함수 상자가 조건에 맞는 줄을 찾음",
|
||||
"Whole table — a function box looks it up",
|
||||
],
|
||||
OneRow: ["줄 하나 고정", "Fix one row"],
|
||||
RowNo: ["몇 번째 줄", "Row number"],
|
||||
Preview: ["미리 보기", "Preview"],
|
||||
Notes: ["주(註)", "Notes"],
|
||||
Remove: ["표 빼기", "Remove table"],
|
||||
Change: ["다른 표 고르기", "Pick another table"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!res.ok) throw new Error(String(data.detail ?? res.statusText));
|
||||
return data;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
}
|
||||
interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
interface RawTable {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
줄: Record<string, unknown>[];
|
||||
주?: string[];
|
||||
}
|
||||
|
||||
const BOOKS = ["산림품셈", "건설품셈"];
|
||||
const files = new Map<string, string>();
|
||||
const tables = new Map<string, RawTable>();
|
||||
|
||||
/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */
|
||||
export async function loadTable(group: CfGroup, key: string): Promise<RawTable> {
|
||||
const hit = tables.get(key);
|
||||
if (hit) return hit;
|
||||
let file = files.get(key);
|
||||
if (!file) {
|
||||
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
|
||||
(x) => x.키 === key,
|
||||
);
|
||||
if (!found) throw new Error(t("Failed"));
|
||||
file = found.file;
|
||||
}
|
||||
const raw = (await get<{ table: RawTable }>("/table", { file, key })).table;
|
||||
tables.set(key, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
const cell = (v: unknown): string =>
|
||||
Array.isArray(v)
|
||||
? v.map((x) => x ?? "").join("~")
|
||||
: v === null || v === undefined
|
||||
? ""
|
||||
: String(v);
|
||||
|
||||
/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */
|
||||
function previewOf(tb: RawTable, row?: number): HTMLElement {
|
||||
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
|
||||
const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1));
|
||||
return el("div", {
|
||||
className: "m02-dfip__preview",
|
||||
children: [
|
||||
el("table", {
|
||||
children: [
|
||||
el("tr", { children: cols.map((c) => el("th", { text: c })) }),
|
||||
...body.map((r, i) =>
|
||||
el("tr", {
|
||||
className: i === row ? "is-hit" : "",
|
||||
children: cols.map((c) => el("td", { text: cell(r[c]) })),
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
...(tb.줄.length > body.length
|
||||
? [el("p", { className: "m02-dfip__muted", text: `… ${tb.줄.length} 줄` })]
|
||||
: []),
|
||||
...(tb.주?.length
|
||||
? [
|
||||
el("details", {
|
||||
children: [
|
||||
el("summary", { text: t("Notes") }),
|
||||
...tb.주.map((n) => el("p", { text: n })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 소요량 · 계수 표 고르기 화면 */
|
||||
export function renderTablePick(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
group: CfGroup,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
const cur = box?.input?.table;
|
||||
if (cur) return void showChosen(host, lg, boxId, group, cur, on);
|
||||
let book = BOOKS[0];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: BOOKS.map((b) => ({ value: b, text: b })),
|
||||
value: book,
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), load()),
|
||||
});
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const tableList = el("div", { className: "m02-dfip__list" });
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
||||
try {
|
||||
const r = await get<{ sections: SectionItem[] }>("/sections", {
|
||||
book,
|
||||
q: q.input.value.trim(),
|
||||
limit: "60",
|
||||
});
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(r.sections.length
|
||||
? r.sections.map((s) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: `${s.section} ${s.title}` }),
|
||||
el("span", {
|
||||
className: "m02-dfip__muted",
|
||||
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pickSection(s));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoSection") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error");
|
||||
}
|
||||
};
|
||||
const pickSection = async (s: SectionItem): Promise<void> => {
|
||||
try {
|
||||
const heads = (
|
||||
await get<{ tables: TableHead[] }>("/tables", {
|
||||
group,
|
||||
section: s.section,
|
||||
sub: book,
|
||||
size: "100",
|
||||
})
|
||||
).tables;
|
||||
tableList.replaceChildren(
|
||||
el("h5", { text: t("Tables") }),
|
||||
...(heads.length
|
||||
? heads.map((h) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: h.이름 }),
|
||||
el("span", {
|
||||
className: "m02-dfip__muted",
|
||||
text: t("TableLine", {
|
||||
b: h.기준 || "—",
|
||||
c: Object.keys(h.조건).length,
|
||||
v: Object.keys(h.값칸).length,
|
||||
n: h.count,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
files.set(h.키, h.file);
|
||||
void loadTable(group, h.키).then(
|
||||
(tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)),
|
||||
(e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"),
|
||||
);
|
||||
});
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoTable") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : t("Failed"), "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
|
||||
);
|
||||
host.replaceChildren(bookBox.root, q.root, list, tableList);
|
||||
void load();
|
||||
}
|
||||
|
||||
/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
group: CfGroup,
|
||||
cur: { key: string; row?: number },
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const body = el("div", { className: "m02-dfip__stack" });
|
||||
host.replaceChildren(body);
|
||||
void loadTable(group, cur.key).then(
|
||||
(tb) => {
|
||||
const whole = cur.row === undefined;
|
||||
const modeWhole = createButton({
|
||||
label: t("Whole"),
|
||||
variant: whole ? "filled" : "ghost",
|
||||
onClick: () => on.change(setTable(lg, boxId, { key: cur.key })),
|
||||
});
|
||||
const modeOne = createButton({
|
||||
label: t("OneRow"),
|
||||
variant: whole ? "ghost" : "filled",
|
||||
onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })),
|
||||
});
|
||||
const rowBox = createSelectField({
|
||||
label: t("RowNo"),
|
||||
options: tb.줄.map((r, i) => ({
|
||||
value: String(i),
|
||||
text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)]
|
||||
.slice(0, 4)
|
||||
.map((c) => cell(r[c]))
|
||||
.join(" / ")}`,
|
||||
})),
|
||||
value: String(cur.row ?? 0),
|
||||
compact: true,
|
||||
onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })),
|
||||
});
|
||||
body.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }),
|
||||
el("div", { className: "m02-dfip__chips", children: [modeWhole, modeOne] }),
|
||||
...(whole ? [] : [rowBox.root]),
|
||||
el("h5", { text: t("Preview") }),
|
||||
previewOf(tb, cur.row),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
children: [
|
||||
createButton({
|
||||
label: t("Change"),
|
||||
variant: "ghost",
|
||||
onClick: () => on.change(setTable(lg, boxId, null)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
(e) =>
|
||||
body.replaceChildren(
|
||||
el("p", {
|
||||
className: "m02-dfip__muted",
|
||||
text: e instanceof Error ? e.message : t("Failed"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user