Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
613 lines
21 KiB
TypeScript
613 lines
21 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|