feat(m01-sl): 시트 로직 표 상자 손잡이 Table 초안 — 품셈 표 가져오기 · 내 표 · 찾기 수식 짜기

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-10-01 13:08:02 +09:00
co-authored by Claude Sonnet 5.5
parent 62fa53e927
commit 7bd6e8d7d1
4 changed files with 1371 additions and 0 deletions
+612
View File
@@ -0,0 +1,612 @@
/* =============================================================================
* 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;
}