Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_desktop_1
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, "")),
|
||||
),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user