Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_desktop_1

This commit is contained in:
2026-09-20 09:02:32 +09:00
5 changed files with 278 additions and 45 deletions
+83 -24
View File
@@ -5,6 +5,7 @@
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -36,8 +37,9 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
let extra: ListItem[] = [];
let marks = new Map<string, ListMark>();
let active: string | null = null;
const book = el("select", { className: "m01-logic__input" });
const chapter = el("select", { className: "m01-logic__input" });
/** 트리에서 고른 범위 — 원문 · 부문 · 장(비면 전체) */
const pick = { book: "", division: "", chapter: "" };
const tree = el("div", { className: "m01-logic__tree" });
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("List_Search") },
@@ -46,25 +48,89 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
const count = el("span", { className: "m01-logic__muted" });
const list = el("ul", { className: "m01-logic__list" });
const options = (select: HTMLSelectElement, all: string, values: string[]): void => {
const keep = select.value;
select.replaceChildren(
el("option", { text: all, attrs: { value: "" } }),
...values.map((v) => el("option", { text: v, attrs: { value: v } })),
);
select.value = values.includes(keep) ? keep : "";
const divisionOf = (chapter: string): string => /^(\S+) \d+장/.exec(chapter)?.[1] ?? "";
const inPick = (x: ListItem): boolean =>
(!pick.book || x.book === pick.book) &&
(!pick.division || divisionOf(x.chapter) === pick.division) &&
(!pick.chapter || x.chapter === pick.chapter);
const make: MakeRow = (node, caret, depth, onClick) => {
const button = el("button", {
className: `m01-side__item${node.id === pickId ? " is-active" : ""}`,
attrs: { type: "button" },
children: [
el("span", {
children: [
el("span", { className: "m01-tree__caret", text: caret }),
el("span", { text: node.label }),
],
}),
el("span", { className: "m01-side__muted", text: String(node.count ?? "") }),
],
});
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
button.addEventListener("click", () => {
onClick();
for (const b of tree.querySelectorAll(".is-active")) b.classList.remove("is-active");
button.classList.add("is-active");
});
return button;
};
let pickId = "";
/** 원문 › 부문 › 장 — 눌러 범위를 고름 */
const drawTree = (): void => {
const count = (f: (x: ListItem) => boolean): number => items.filter(f).length;
const books = [...new Set(items.map((x) => x.book))];
const set = (id: string, book: string, division: string, chapter: string): void => {
pickId = id;
Object.assign(pick, { book, division, chapter });
draw();
};
const nodes: TreeNode[] = [
{
id: "all",
label: tx("List_AllBooks"),
count: items.length,
open: true,
run: () => set("all", "", "", ""),
children: books.map((b): TreeNode => {
const chapters = [...new Set(items.filter((x) => x.book === b).map((x) => x.chapter))];
const leaf = (c: string, label: string): TreeNode => ({
id: `${b}|${c}`,
label,
count: count((x) => x.book === b && x.chapter === c),
run: () => set(`${b}|${c}`, b, "", c),
});
const divisions = [...new Set(chapters.map(divisionOf).filter(Boolean))];
return {
id: b,
label: b,
count: count((x) => x.book === b),
run: () => set(b, b, "", ""),
children: [
...chapters.filter((c) => !divisionOf(c)).map((c) => leaf(c, c)),
...divisions.map((d): TreeNode => ({
id: `${b}|#${d}`,
label: d,
count: count((x) => x.book === b && divisionOf(x.chapter) === d),
run: () => set(`${b}|#${d}`, b, d, ""),
children: chapters
.filter((c) => divisionOf(c) === d)
.map((c) => leaf(c, c.slice(d.length + 1))),
})),
],
};
}),
},
];
tree.replaceChildren(...renderTree(nodes, make));
};
const chapters = (): void =>
options(chapter, tx("List_AllChapters"), [
...new Set(items.filter((x) => !book.value || x.book === book.value).map((x) => x.chapter)),
]);
const draw = (): void => {
const q = search.value.trim().toLowerCase();
const shown = [...extra, ...items].filter(
(x) =>
(!book.value || x.book === book.value) &&
(!chapter.value || x.chapter === chapter.value) &&
inPick(x) &&
(!blockedOnly.checked || x.blocked) &&
(!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))),
);
@@ -103,11 +169,6 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
return el("li", { children: [button] });
};
book.addEventListener("change", () => {
chapters();
draw();
});
chapter.addEventListener("change", draw);
search.addEventListener("input", draw);
blockedOnly.addEventListener("change", draw);
@@ -117,8 +178,6 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
el("div", {
className: "m01-logic__filters",
children: [
book,
chapter,
search,
el("label", {
className: "m01-logic__check",
@@ -130,6 +189,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
list,
],
});
root.prepend(tree);
return {
root,
setItems: (logics) => {
@@ -143,8 +203,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
blocked: x.blocked,
reasons: x.reasons,
}));
options(book, tx("List_AllBooks"), [...new Set(items.map((x) => x.book))]);
chapters();
drawTree();
draw();
},
setMarks: (next, more) => {
+26 -1
View File
@@ -34,6 +34,7 @@ const SIZE = 50;
const MARKET = "재료_시중물가.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
const NOTE = "비고";
const SUPPLY = ["값", "조달"];
const SOURCE = [...SUPPLY, "출처"];
const NARA = "나라장터:";
@@ -49,6 +50,14 @@ function blank(sample: Row): Row {
return out;
}
/** 비고 칸 고침 — 빈 글이면 칸을 지움 */
function withNote(row: Row, text: string): Row {
const out = { ...row };
if (text.trim()) out[NOTE] = text;
else delete out[NOTE];
return out;
}
/** 요소별 전용 칸으로 그리는 칸 — 일반 칸에서 뺌. */
function hidden(file: string, col: string): boolean {
if (file === MARKET) return col === `${SEP}조달`;
@@ -97,7 +106,7 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "")
const cols: string[] = [];
for (const row of [...adds, ...d.rows]) {
for (const k of Object.keys(flatten(row)))
if (!hidden(file, k) && !cols.includes(k)) cols.push(k);
if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k);
}
const head = el("tr", {
children: [
@@ -106,6 +115,7 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "")
...(HEADS[file] ?? []).map((h) =>
el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }),
),
el("th", { text: NOTE }), // 비고 — 항상 끝 열(줄에 없으면 빈 칸 · 눌러 적음)
],
});
const body = el("tbody");
@@ -122,6 +132,12 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "")
}),
);
}
tr.append(
buildCell(show(row[NOTE]), {
changed: true,
onEdit: (text) => setAdd(file, i, withNote(row, text)),
}),
);
body.append(tr);
});
@@ -156,6 +172,15 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "")
if (file === MARKET) tr.append(...marketCells(row, cur, put));
if (file === LINKED) tr.append(linkedCell(row, cur, put));
if (JOB.includes(file)) tr.append(wageCell(row, cur, put));
tr.append(
buildCell(show(cur[NOTE]), {
changed: !same(cur[NOTE], row[NOTE]),
onEdit:
deleted || !isScalar(row[NOTE])
? undefined
: (text) => editRow(file, d.version, key, row, withNote(cur, text)),
}),
);
body.append(tr);
}
+74 -20
View File
@@ -8,7 +8,8 @@
import { el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { t as L } from "@ui/ui_template_locale";
import { fetchFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
import { fetchFiles, fetchRows, type FileInfo } from "./M01_MasterData_Api_Fetch";
import { bookTree, renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
/** 요소 화면에서 열 것 — 파일 · 하위 거름(조사·세부분류) · 제목 */
export interface Pick {
@@ -62,26 +63,39 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
const counts = new Map<string, number>();
const setActive = (id: string | null): void => {
for (const [key, button] of buttons) button.classList.toggle("is-active", key === id);
};
const item = (id: string, text: string, count: number | null, run: () => void): HTMLElement => {
/** 트리 줄 — 부모는 접기/펴기 · 누를 일이 있는 줄만 밝게 */
const make: MakeRow = (node, caret, depth, onClick) => {
const button = el("button", {
className: "m01-side__item",
attrs: { type: "button" },
children: [
el("span", { text }),
...(count === null
el("span", {
children: [
el("span", { className: "m01-tree__caret", text: caret }),
el("span", { text: node.label }),
],
}),
...(node.count === null
? []
: [el("span", { className: "m01-side__muted", text: count.toLocaleString("ko-KR") })]),
: [
el("span", {
className: "m01-side__muted",
text: node.count.toLocaleString("ko-KR"),
}),
]),
],
});
buttons.set(id, button);
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
if (node.run) buttons.set(node.id, button);
button.addEventListener("click", () => {
setActive(id);
run();
if (node.run) setActive(node.id);
onClick();
});
return button;
};
@@ -95,27 +109,67 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
const one = files[0];
if (group === "인력" || group === "기계") {
const kinds = group === "인력" ? LABOR_SURVEYS : MACHINE_KINDS;
body.replaceChildren(
...(one
? [
item(`${group}\n`, L("M01_All"), one.rows, () => open(one, "", L("M01_All"))),
...kinds.map((k) => item(`${group}\n${k}`, k, null, () => open(one, k, k))),
]
: []),
const all: TreeNode[] = one
? [
{
id: `${group}\n`,
label: L("M01_All"),
count: one.rows,
open: true,
run: () => open(one, "", L("M01_All")),
children: kinds.map((k) => ({
id: `${group}\n${k}`,
label: k,
count: counts.get(`${group}\n${k}`) ?? null,
run: () => open(one, k, k),
})),
},
]
: [];
body.replaceChildren(...renderTree(all, make));
return;
}
if (group === "소요량" || group === "계수") {
const tree = bookTree(
files,
(f, division): TreeNode => {
const label = fileLabel(f.file);
return {
id: `${group}\n${f.file}`,
label: division ? f.chapter.slice(division.length + 1) : f.chapter,
count: f.rows,
run: () => open(f, "", label),
};
},
(parts) => `${group}\n#${parts.join("\n")}`,
(list) => list.reduce((n, f) => n + f.rows, 0),
);
body.replaceChildren(...renderTree(tree, make));
return;
}
body.replaceChildren(
...files.map((f) => {
const name = fileLabel(f.file);
const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name;
return item(`${group}\n${f.file}`, label, f.rows, () => open(f, "", label));
}),
...renderTree(
files.map((f) => {
const name = fileLabel(f.file);
const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name;
return { id: `${group}\n${f.file}`, label, count: f.rows, run: () => open(f, "", label) };
}),
make,
),
);
};
const refresh = async (group: string): Promise<FileInfo[]> => {
const list = await fetchFiles(group);
const kinds = group === "인력" ? LABOR_SURVEYS : group === "기계" ? MACHINE_KINDS : [];
if (list[0] && kinds.length) {
const file = list[0].file;
await Promise.all(
kinds.map(async (k) =>
counts.set(`${group}\n${k}`, (await fetchRows(file, 1, 1, "", false, k)).total),
),
);
}
lists.set(group as Group, list);
drawGroup(group as Group);
return list;
@@ -67,6 +67,24 @@
color: var(--color-accent);
}
.m01-tree__kids {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-tree__branch {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-tree__caret {
display: inline-block;
width: var(--spacing-12);
color: var(--color-text-muted);
}
.m01-side__muted,
.m01-master__muted {
color: var(--color-text-muted);
+77
View File
@@ -0,0 +1,77 @@
/* =============================================================================
* M01_MasterData_UI_Tree.ts
* 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
export interface TreeNode {
id: string;
label: string;
/** 줄 수 — null 이면 안 보임 */
count: number | null;
/** 눌렀을 때 — 없으면 접기/펴기만 */
run?: () => void;
children?: TreeNode[];
/** 처음부터 펴짐 */
open?: boolean;
}
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
export type MakeRow = (
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
) => HTMLElement;
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
return nodes.map((node) => {
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
const kids = el("div", {
className: "m01-tree__kids",
children: renderTree(node.children, make, depth + 1),
});
kids.hidden = !node.open;
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
kids.hidden = !kids.hidden;
const caret = row.querySelector(".m01-tree__caret");
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
node.run?.();
});
return el("div", { className: "m01-tree__branch", children: [row, kids] });
});
}
/** 파일 목록 → 원문 › 부문 › 장 — 장 이름이 「공통 03장 …」 이면 부문 「공통」 */
export function bookTree<T extends { book: string | null; chapter: string }>(
items: T[],
leaf: (item: T, division: string) => TreeNode,
id: (parts: string[]) => string,
count: (list: T[]) => number,
): TreeNode[] {
const books = new Map<string, Map<string, T[]>>();
for (const it of items) {
const division = /^(\S+) \d+장/.exec(it.chapter)?.[1] ?? "";
const divs = books.get(it.book ?? "") ?? new Map<string, T[]>();
divs.set(division, [...(divs.get(division) ?? []), it]);
books.set(it.book ?? "", divs);
}
return [...books].map(([book, divs]) => ({
id: id([book]),
label: book,
count: count([...divs.values()].flat()),
children: [...divs].flatMap(([division, list]) =>
division
? [
{
id: id([book, division]),
label: division,
count: count(list),
children: list.map((it) => leaf(it, division)),
},
]
: list.map((it) => leaf(it, "")),
),
}));
}