Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Table.ts
T

613 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M01_SL_Table.ts
* 시트 로직 표 상자 손잡이 — 표 상자를 고르면 시트 판 위에 붙음
* · 품셈 표 가져오기(절 · 이름 찾기 → 표) — 표 줄은 바깥 값(사본으로 안 들고 있음)
* · 내 표(빈 표 · 엑셀에서 붙여넣기) — 시트에 그대로
* · 조건 칸 · 결과 칸 정하기 → 입력 알약 · 출력 알약 + 「표에서 찾기」 수식(시트 판에서 고칠 수 있음)
* · 걸린 줄 칠하기 · 맞는 줄 없음 / 둘 이상 한 줄
* 짜기 = `M01_SL_Table_Build.ts`(DOM 없음) · 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 10
* ========================================================================== */
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 { ApiError } from "../M01_MasterData_UI_Logic_Api";
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
import type { SheetLogic, SlNode } from "./M01_SL_Model";
import {
applyTable,
blockCells,
infoFromSheet,
layoutOf,
parsePaste,
planLookup,
type TblInfo,
type TblRoles,
} from "./M01_SL_Table_Build";
import "./M01_SL_Table.css";
const TEXT = {
NotTable: ["표 상자를 고르면 표 손잡이가 뜸", "Pick a table box to see its tools"],
Import: ["품셈 표 가져오기", "Import a standard table"],
Mine: ["내 표", "My table"],
Book: ["적용 품셈", "Standard"],
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
Loading: ["찾는 중…", "Searching…"],
NoSection: ["찾는 절 없음", "No section"],
SecLine: ["표 {t} 개", "{t} tables"],
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
TableLine: [
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
"{b} · {c} conditions · {v} results · {n} rows",
],
ImportNote: [
"표 줄은 열 때 · 저장 때 품셈에서 새로 넣음 — 사본으로 안 들고 있음",
"Rows are filled from the standard when opened or saved",
],
Empty: ["빈 표", "Empty table"],
Paste: ["엑셀에서 붙여넣기", "Paste from Excel"],
Heads: ["머리 칸 이름(쉼표로 나눔) — 예: 경사, 할증", "Column names, comma separated"],
RowCount: ["줄 수", "Rows"],
MakeEmpty: ["빈 표 만들기", "Create"],
PasteHint: [
"엑셀에서 머리 줄부터 복사해 여기에 붙임(탭으로 나뉜 글)",
"Paste with the header row (tab separated)",
],
PutIn: ["시트에 넣기", "Put in sheet"],
TableName: ["표 이름", "Table name"],
Need2: ["머리 칸이 둘은 있어야 함", "Need at least two columns"],
NeedRows: ["줄 수를 1 이상으로", "Rows must be 1 or more"],
Source: ["원문번호 {n} · 기준 {b} · 출처 {s}", "No. {n} · base {b} · source {s}"],
Notes: ["주(註)", "Notes"],
Roles: ["칸 역할 — 누르면 바뀜", "Column roles — press to change"],
RoleCond: ["조건 → 입력 알약", "Condition → input pill"],
RoleOut: ["결과 → 출력 알약", "Result → output pill"],
RoleOff: ["쓰지 않음", "Unused"],
Redo: ["역할을 바꾸면 찾기 수식을 다시 씀", "Changing roles rewrites the lookup formulas"],
Found: ["{n} 번째 줄이 걸림", "Row {n} matched"],
NoRow: ["맞는 줄 없음 — 입력 칸 값에 맞는 줄이 표에 없음", "No matching row"],
TwoRows: ["맞는 줄 둘 이상 — 조건 칸을 더 늘리거나 표 줄을 고침", "More than one matching row"],
Err: ["찾기 칸에 오류가 있음", "Error in the lookup cells"],
NoValues: ["표 줄을 아직 못 받음 — 열 때 품셈에서 넣음", "Rows are not loaded yet"],
View: ["걸린 줄 보기", "Matched row"],
Formulas: ["찾기 수식 — 시트 판에서 고침", "Lookup formulas — edit in the sheet"],
Warn: ["알림", "Note"],
Swap: ["다른 표로 바꾸기", "Change 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 ApiError(res.status, 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;
출처: string;
조건: Record<string, string>;
값칸: Record<string, string>;
범위규칙?: string | null;
줄: TblInfo["rows"];
주?: string[];
}
const BOOKS = ["산림품셈", "건설품셈"];
const cache = new Map<string, TblInfo>();
const fileOf = new Map<string, string>();
const toInfo = (r: RawTable): TblInfo => ({
key: r.키,
name: r.이름,
base: r.기준 ?? "",
source: r.출처 ?? "",
no: r.원문번호 ?? "",
conds: r.조건,
vals: r.값칸,
rule: r.범위규칙 ?? null,
rows: r.줄,
notes: r.주 ?? [],
});
/** 표 키 → 표 한 벌 — 파일은 목록에서 찾음(첫 가져오기 뒤에는 기억) */
async function loadInfo(key: string): Promise<TblInfo> {
const hit = cache.get(key);
if (hit) return hit;
let file = fileOf.get(key);
if (!file) {
const group = /^C[FC]/.test(key) ? "계수" : "소요량";
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 info = toInfo((await get<{ table: RawTable }>("/table", { file, key })).table);
cache.set(key, info);
return info;
}
/* ── 역할 · 알약 ── */
const rolesOf = (node: SlNode): TblRoles => ({
cond: node.pillsIn.map((p) => p.name),
out: node.pillsOut.filter((p) => !p.outside).map((p) => p.name),
});
/** 내 표 읽기 — 시트 블록에서 · 조건 칸은 입력 알약 이름 */
function mineInfo(sl: SheetLogic, node: SlNode): TblInfo {
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
const name = node.name.replace(/^표 · /, "");
const conds = Object.fromEntries(node.pillsIn.map((p) => [p.name, "고르기"]));
return sheet
? infoFromSheet(sheet, { conds, name })
: {
key: "",
name,
base: "",
source: "",
no: "",
conds,
vals: {},
rule: null,
rows: [],
notes: [],
};
}
/** userTables 한 줄을 시트 모양으로 맞춤 */
function syncUser(sl: SheetLogic, id: string, info: TblInfo): SheetLogic {
const cols = layoutOf(info);
const rows = info.rows.map((r) =>
cols.map(
(c) =>
(Array.isArray(r[c.of])
? ((r[c.of] as [number | null, number | null])[c.part === "아래" ? 0 : 1] ?? null)
: (r[c.of] ?? null)) as number | string | null,
),
);
const entry = { id, name: info.name, cols: cols.map((c) => c.header), rows };
const list = (sl.userTables ?? []).filter((u) => u.id !== id);
return { ...sl, userTables: [...list, entry] };
}
/** 찾기 판을 임시 통합문서에서 풀어 → 상태 · 걸린 줄(0 부터) · 오류 */
async function solve(
sl: SheetLogic,
node: SlNode,
info: TblInfo,
withBlock: boolean,
): Promise<{ status: string; idx: number | null }> {
const plan = planLookup(info, rolesOf(node));
const book = structuredClone(sl.workbook);
const sheet = book.시트.find((s) => s.id === node.sheetId);
if (!sheet) return { status: "", idx: null };
if (withBlock) Object.assign(sheet.칸, blockCells(info));
const { createCalcEngine } = await import("../../A00_Common/spreadsheet/spreadsheet_graph");
const vals = createCalcEngine(book).snapshot()[node.sheetId] ?? {};
const read = (a: string): string | null => {
const v = vals[a] as Record<string, unknown> | undefined;
if (!v) return null;
return String(v["수"] ?? v["글"] ?? v["오류"] ?? "");
};
const status = read(plan.statusCell) ?? "";
const idx = Number(read(plan.idxCell));
return { status, idx: Number.isFinite(idx) && idx >= 1 ? idx - 1 : null };
}
/* ── 화면 ── */
export function renderTableTools(
host: HTMLElement,
sl: SheetLogic,
nodeId: string,
on: { change(sl: SheetLogic): void },
): void {
host.className = "m01-sltbl";
const node = sl.nodes.find((n) => n.id === nodeId);
if (!node || node.kind !== "table") {
host.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("NotTable") }));
return;
}
const mine = node.ref ? sl.userTables?.find((u) => u.id === node.ref) : undefined;
const state = !node.ref ? "empty" : mine ? "mine" : "base";
const parts: HTMLElement[] = [];
if (state !== "empty") parts.push(infoBox(sl, node, state, on));
parts.push(
importBox(sl, nodeId, state === "empty", on),
mineBox(sl, nodeId, state === "empty", on),
);
host.replaceChildren(...parts);
}
const sub = (title: string, open: boolean, ...kids: HTMLElement[]): HTMLDetailsElement => {
const d = el("details", {
className: "m01-sltbl__box",
children: [el("summary", { text: title }), ...kids],
});
d.open = open;
return d;
};
/** 품셈 표 가져오기 */
function importBox(
sl: SheetLogic,
nodeId: string,
open: boolean,
on: { change(sl: SheetLogic): void },
): HTMLElement {
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: "m01-sltbl__list" });
const tables = el("div", { className: "m01-sltbl__list" });
let seq = 0;
const load = async (): Promise<void> => {
const mineSeq = ++seq;
list.replaceChildren(el("p", { className: "m01-sltbl__muted", text: t("Loading") }));
try {
const r = await get<{ sections: SectionItem[] }>("/sections", {
book,
q: q.input.value.trim(),
limit: "60",
});
if (mineSeq !== seq) return;
list.replaceChildren(
...(r.sections.length
? r.sections.map((s) => {
const b = el("button", {
className: "m01-sltbl__row",
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "m01-sltbl__muted",
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "m01-sltbl__muted", text: t("NoSection") })]),
);
} catch (e) {
if (mineSeq === seq) showToast(plainError(e), "error");
}
};
const pickSection = async (s: SectionItem): Promise<void> => {
try {
const heads: TableHead[] = [];
for (const group of ["소요량", "계수"])
heads.push(
...(
await get<{ tables: TableHead[] }>("/tables", {
group,
section: s.section,
sub: book,
size: "100",
})
).tables,
);
tables.replaceChildren(
el("h5", { text: t("Tables") }),
...heads.map((h) => {
const b = el("button", {
className: "m01-sltbl__row",
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "m01-sltbl__muted",
text: t("TableLine", {
b: h.기준 || "—",
c: Object.keys(h.조건).length,
v: Object.keys(h.값칸).length,
n: h.count,
}),
}),
],
});
b.addEventListener("click", () => void pickTable(h));
return b;
}),
);
} catch (e) {
showToast(plainError(e), "error");
}
};
const pickTable = async (h: TableHead): Promise<void> => {
try {
fileOf.set(h.키, h.file);
const info = await loadInfo(h.키);
const roles: TblRoles = { cond: Object.keys(info.conds), out: Object.keys(info.vals) };
on.change(
applyTable(sl, nodeId, info, roles, { ref: h.키, fillBlock: false, outside: true }).sl,
);
} catch (e) {
showToast(plainError(e), "error");
}
};
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
const box = sub(
t("Import"),
open,
el("p", { className: "m01-sltbl__muted", text: t("ImportNote") }),
bookBox.root,
q.root,
list,
tables,
);
box.addEventListener("toggle", () => box.open && !list.childElementCount && void load());
if (open) void load();
return box;
}
/** 내 표 — 빈 표 · 붙여넣기 */
function mineBox(
sl: SheetLogic,
nodeId: string,
open: boolean,
on: { change(sl: SheetLogic): void },
): HTMLElement {
const name = createInputField({
type: "text",
label: t("TableName"),
value: "내 표",
compact: true,
});
const heads = createInputField({ type: "text", placeholder: t("Heads"), compact: true });
const rows = createInputField({ type: "text", label: t("RowCount"), value: "2", compact: true });
const paste = el("textarea", {
className: "m01-sltbl__paste",
attrs: { rows: "5", placeholder: t("PasteHint") },
});
const put = (info: TblInfo, roles: TblRoles): void => {
const id = `UT${(sl.userTables?.length ?? 0) + 1}`;
const done = applyTable(
sl,
nodeId,
{ ...info, name: name.input.value.trim() || "내 표" },
roles,
{ ref: id, fillBlock: true, outside: false },
);
on.change(syncUser(done.sl, id, { ...info, name: name.input.value.trim() || "내 표" }));
};
const makeEmpty = createButton({
label: t("MakeEmpty"),
onClick: () => {
const cols = heads.input.value
.split(/[,,\t]/)
.map((s) => s.trim())
.filter(Boolean);
const n = Math.floor(Number(rows.input.value));
if (cols.length < 2) return heads.setError(t("Need2"));
if (!(n >= 1)) return rows.setError(t("NeedRows"));
const info: TblInfo = {
key: "",
name: "",
base: "",
source: "",
no: "",
conds: { [cols[0]]: "고르기" },
vals: Object.fromEntries(cols.slice(1).map((c) => [c, ""])),
rule: null,
rows: Array.from({ length: n }, () => ({})),
notes: [],
};
put(info, { cond: [cols[0]], out: cols.slice(1) });
},
});
const pasteBtn = createButton({
label: t("PutIn"),
onClick: () => {
const r = parsePaste(paste.value);
if (r.error) return void showToast(r.error, "error");
put(r.info, { cond: Object.keys(r.info.conds), out: Object.keys(r.info.vals) });
},
});
return sub(
t("Mine"),
open,
name.root,
el("h5", { text: t("Empty") }),
heads.root,
rows.root,
makeEmpty,
el("h5", { text: t("Paste") }),
paste,
pasteBtn,
);
}
/** 고른 표 — 머리 · 칸 역할 · 걸린 줄 · 수식 */
function infoBox(
sl: SheetLogic,
node: SlNode,
state: "base" | "mine",
on: { change(sl: SheetLogic): void },
): HTMLElement {
const box = el("div", { className: "m01-sltbl__info" });
const meta = el("p", { className: "m01-sltbl__muted" });
const notes = el("details", { className: "m01-sltbl__notes" });
const view = el("div", { className: "m01-sltbl__view" });
const status = el("p", { className: "m01-sltbl__status", attrs: { "aria-live": "polite" } });
const roleBox = el("div", { className: "m01-sltbl__roles" });
const formulas = el("div", { className: "m01-sltbl__formulas" });
const roles = rolesOf(node);
const paint = (info: TblInfo): void => {
const withBlock = state === "base";
meta.textContent =
info.no || info.source
? t("Source", { n: info.no || "—", b: info.base || "—", s: info.source || "—" })
: "";
notes.replaceChildren(
...(info.notes.length
? [el("summary", { text: t("Notes") }), ...info.notes.map((n) => el("p", { text: n }))]
: []),
);
notes.hidden = !info.notes.length;
// 칸 역할
const all = [
...Object.keys(info.conds).map((c) => ({ c, isCond: true })),
...Object.keys(info.vals).map((c) => ({ c, isCond: false })),
];
const cur = (c: string): "cond" | "out" | "off" =>
roles.cond.includes(c) ? "cond" : roles.out.includes(c) ? "out" : "off";
roleBox.replaceChildren(
el("p", { className: "m01-sltbl__muted", text: `${t("Roles")} · ${t("Redo")}` }),
...all.map(({ c, isCond }) => {
const r = cur(c);
const b = el("button", {
className: `m01-sltbl__role is-${r}`,
attrs: { type: "button" },
children: [
el("strong", { text: c }),
el("span", {
text: r === "cond" ? t("RoleCond") : r === "out" ? t("RoleOut") : t("RoleOff"),
}),
],
});
b.addEventListener("click", () => {
const next: TblRoles = {
cond: roles.cond.filter((x) => x !== c),
out: roles.out.filter((x) => x !== c),
};
const to = r === "off" ? (isCond ? "cond" : "out") : "off";
if (to === "cond") next.cond.push(c);
if (to === "out") next.out.push(c);
const order = Object.keys(info.conds);
next.cond.sort((a, z) => order.indexOf(a) - order.indexOf(z));
const done = applyTable(sl, node.id, info, next, {
ref: node.ref ?? "",
fillBlock: false,
outside: state === "base",
});
on.change(state === "mine" ? syncUser(done.sl, node.ref ?? "", info) : done.sl);
});
return b;
}),
);
// 수식 보기
const plan = planLookup(info, roles);
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
const line = (label: string, a1: string): HTMLElement =>
el("div", {
className: "m01-sltbl__formula",
children: [
el("span", { className: "m01-sltbl__muted", text: label }),
el("code", {
text: sheet?.칸[a1]?.식 ? `=${sheet.칸[a1].식}` : String(sheet?.칸[a1]?.값 ?? ""),
}),
],
});
formulas.replaceChildren(
el("h5", { text: t("Formulas") }),
...Object.entries(plan.outCells).map(([c, a1]) => line(`${c} (${a1})`, a1)),
...(plan.warnings.length
? [
el("p", {
className: "m01-sltbl__warn",
text: `${t("Warn")}: ${plan.warnings.join(" · ")}`,
}),
]
: []),
);
// 걸린 줄 칠하기
const cols = layoutOf(info);
const table = el("table");
table.append(el("tr", { children: cols.map((c) => el("th", { text: c.header })) }));
info.rows.forEach((r, i) =>
table.append(
el("tr", {
attrs: { "data-row": String(i) },
children: cols.map((c) => {
const v = r[c.of];
const x = Array.isArray(v) ? (c.part === "아래" ? v[0] : v[1]) : v;
return el("td", { text: x === null || x === undefined ? "" : String(x) });
}),
}),
),
);
view.replaceChildren(
el("h5", { text: t("View") }),
el("div", { className: "m01-sltbl__scroll", children: [table] }),
);
void solve(sl, node, info, withBlock)
.then(({ status: s, idx }) => {
status.className = `m01-sltbl__status is-${s === "찾음" ? "ok" : "bad"}`;
status.textContent =
s === "찾음" && idx !== null
? t("Found", { n: idx + 1 })
: s === "맞는 줄 없음"
? t("NoRow")
: s === "맞는 줄 둘 이상"
? t("TwoRows")
: s
? t("Err")
: "";
table
.querySelectorAll("tr[data-row]")
.forEach((tr) =>
tr.classList.toggle(
"is-hit",
idx !== null && s === "찾음" && tr.getAttribute("data-row") === String(idx),
),
);
table.querySelector(".is-hit")?.scrollIntoView({ block: "nearest" });
})
.catch(() => (status.textContent = t("Err")));
};
box.append(meta, notes, roleBox, status, view, formulas);
if (state === "base") {
const key = node.ref ?? "";
void loadInfo(key).then(paint, (e) => showToast(plainError(e), "error"));
} else paint(mineInfo(sl, node));
return box;
}