feat(m02): 사전조합 나무 분류 폴더 고정 · 폴더 메뉴 새 사전조합(149-1)
- 나무 = 구조물군 › 공통 + 종류 뼈대 먼저(빈 폴더도) · 목록 밖 값 뒤 · 미분류 맨 뒤 - 폴더 수 = 문서 수 · 문서 든 폴더만 처음 펼침 · 손으로 펴고 접은 것 기억 - 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성 → 상세 엶(못 쓰는 층은 흐림) - 이름 창(nameDialog) 목록표 밖으로 꺼내 함께 씀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
@@ -143,6 +143,7 @@ const TEXT = {
|
||||
Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"],
|
||||
Lib_None: ["사전조합 없음", "No pre-combination"],
|
||||
Lib_Unsorted: ["미분류", "Unsorted"],
|
||||
Lib_NewHere: ["여기에 새 사전조합", "New pre-combination here"],
|
||||
Lib_Create: ["생성", "Create"],
|
||||
Lib_Copy: ["복사", "Copy"],
|
||||
Lib_Delete: ["삭제", "Delete"],
|
||||
|
||||
@@ -59,6 +59,41 @@ const money = (v: number | null | undefined): string =>
|
||||
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름);
|
||||
|
||||
/** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */
|
||||
export const nameDialog = (
|
||||
title: string,
|
||||
start: string,
|
||||
onOk: (name: string) => Promise<void>,
|
||||
): void => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
name.input.value = start;
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (box, close) => {
|
||||
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
name.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) ok.click();
|
||||
});
|
||||
box.append(name.root, ok);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */
|
||||
export function mountLibraryList(
|
||||
host: HTMLElement,
|
||||
@@ -142,36 +177,6 @@ export function mountLibraryList(
|
||||
draw();
|
||||
};
|
||||
|
||||
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
name.input.value = start;
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (box, close) => {
|
||||
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
name.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) ok.click();
|
||||
});
|
||||
box.append(name.root, ok);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */
|
||||
const made = (key: string, label: string): void => {
|
||||
showToast(L("M02_Created"), "success");
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
||||
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류).
|
||||
* 폴더 = 분류 뼈대 고정(149-1 · 구조물군 › 공통 + 종류 · 빈 폴더도 · 문서 수 · 문서 든 폴더만 펼침) ·
|
||||
* 문서 옮김 = 상세 머리에서 분류 바꿈 · 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성.
|
||||
* 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표
|
||||
* (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree";
|
||||
import { listTemplates, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { listTemplates, newLibrary, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { nameDialog } from "./M02_MasterTemplete_Library_List";
|
||||
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
|
||||
import type { Root } from "./M02_MasterTemplete_Layer";
|
||||
import type { StructDef } from "./StructOptions/M02_SO_Def_Model";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -78,12 +83,18 @@ interface Row {
|
||||
상세구분?: string | null;
|
||||
}
|
||||
|
||||
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */
|
||||
type Classes = { name: string; details: string[] }[];
|
||||
|
||||
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) ·
|
||||
* `classes`(149-1) = 분류 뼈대 먼저(그 차례 · 빈 묶음도) → 목록 밖 값은 뒤에 가나다 */
|
||||
export function groupRows(
|
||||
rows: Row[],
|
||||
unsorted: string,
|
||||
classes: Classes = [],
|
||||
): { 구분: string; 상세: Map<string, Row[]> }[] {
|
||||
const out = new Map<string, Map<string, Row[]>>();
|
||||
const out = new Map<string, Map<string, Row[]>>(
|
||||
classes.map((c) => [c.name, new Map(c.details.map((d) => [d, []]))]),
|
||||
);
|
||||
for (const row of rows) {
|
||||
const top = row.구분 || "";
|
||||
const sub = row.구분 ? row.상세구분 || "" : "";
|
||||
@@ -93,18 +104,30 @@ export function groupRows(
|
||||
}
|
||||
const order = (a: string, b: string): number =>
|
||||
!a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true });
|
||||
return [...out.keys()].sort(order).map((top) => ({
|
||||
구분: top || unsorted,
|
||||
상세: new Map([...out.get(top)!].sort(([a], [b]) => order(a, b))),
|
||||
}));
|
||||
const rank = (list: string[], key: string): number =>
|
||||
list.includes(key) ? list.indexOf(key) : list.length;
|
||||
const tops = classes.map((c) => c.name);
|
||||
return [...out.keys()]
|
||||
.sort((a, b) => rank(tops, a) - rank(tops, b) || order(a, b))
|
||||
.map((top) => {
|
||||
const subs = classes.find((c) => c.name === top)?.details ?? [];
|
||||
return {
|
||||
구분: top || unsorted,
|
||||
상세: new Map(
|
||||
[...out.get(top)!].sort(([a], [b]) => rank(subs, a) - rank(subs, b) || order(a, b)),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
const barHost = el("div");
|
||||
const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } });
|
||||
const leaves = new Map<string, HTMLElement>();
|
||||
const closed = new Set<string>(); // 접은 폴더 — 다시 그려도 그대로
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
let rows: Row[] = [];
|
||||
let classes: Promise<Classes> | null = null; // 분류 뼈대(구조물정의 · 한 번 받음 · 못 받으면 다음에 다시)
|
||||
let shape: Classes = [];
|
||||
let seq = 0;
|
||||
|
||||
const open = async (row: Row): Promise<void> => {
|
||||
@@ -123,37 +146,67 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
count: null,
|
||||
run: () => void open(row),
|
||||
});
|
||||
const folder = (id: string, label: string, children: TreeNode[]): TreeNode => ({
|
||||
id,
|
||||
label,
|
||||
count: children.length,
|
||||
folder: true,
|
||||
open: !closed.has(id),
|
||||
children,
|
||||
});
|
||||
/** 폴더 메뉴 — [여기에 새 사전조합] = 그 구분 · 상세구분으로 생성 → 나무 다시 · 상세 엶 */
|
||||
const make = (구분: string, 상세구분: string): void => {
|
||||
const at = opt.scope();
|
||||
nameDialog(tc("Lib_NewHere"), "", async (name) => {
|
||||
const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] };
|
||||
const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner);
|
||||
showToast(L("M02_Created"), "success");
|
||||
await refresh();
|
||||
await open({ 이름: got.이름, 종류: "precombo", 제목: name });
|
||||
});
|
||||
};
|
||||
const docs = (node: TreeNode): number =>
|
||||
node.run ? 1 : (node.children ?? []).reduce((n, c) => n + docs(c), 0);
|
||||
const folder = (
|
||||
id: string,
|
||||
label: string,
|
||||
children: TreeNode[],
|
||||
at: [string, string],
|
||||
): TreeNode => {
|
||||
const count = children.reduce((n, c) => n + docs(c), 0);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
count,
|
||||
folder: true,
|
||||
open: toggled.get(id) ?? count > 0,
|
||||
children,
|
||||
menu: () => [
|
||||
{
|
||||
label: tc("Lib_NewHere"),
|
||||
run: () => make(...at),
|
||||
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
leaves.clear();
|
||||
if (!rows.length)
|
||||
if (!rows.length && !shape.length)
|
||||
return void treeHost.replaceChildren(
|
||||
el("p", { className: "m02-side__empty", text: tc("Lib_None") }),
|
||||
);
|
||||
const nodes = groupRows(rows, tc("Lib_Unsorted")).map((g) =>
|
||||
folder(
|
||||
const unsorted = tc("Lib_Unsorted");
|
||||
const nodes = groupRows(rows, unsorted, shape).map((g) => {
|
||||
const top = g.구분 === unsorted ? "" : g.구분;
|
||||
return folder(
|
||||
`sub:${g.구분}`,
|
||||
g.구분,
|
||||
[...g.상세].flatMap(([sub, list]) =>
|
||||
sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf))] : list.map(leaf),
|
||||
sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf), [top, sub])] : list.map(leaf),
|
||||
),
|
||||
),
|
||||
);
|
||||
[top, top ? ([...g.상세.keys()][0] ?? "") : ""],
|
||||
);
|
||||
});
|
||||
treeHost.replaceChildren(
|
||||
...renderTree(nodes, (node, caret, depth, onClick) => {
|
||||
const row = createTreeRow(node, caret, depth, () => {
|
||||
onClick();
|
||||
if (!node.children?.length) return;
|
||||
if (row.querySelector(".ui-tree__caret")?.textContent === "▸") closed.add(node.id);
|
||||
else closed.delete(node.id);
|
||||
toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸");
|
||||
});
|
||||
if (node.run) {
|
||||
row.dataset.precomboKey = node.id.slice(4);
|
||||
@@ -178,14 +231,20 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") }));
|
||||
const shapeAt = (classes ??= fetchStructureDef().then(precomboClasses, () => {
|
||||
classes = null;
|
||||
return []; // 못 받으면 문서 값만으로 묶음(지난 모양)
|
||||
}));
|
||||
let got: Row[] = [];
|
||||
try {
|
||||
got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo");
|
||||
} catch {
|
||||
got = []; // 못 받으면 빈 나무(목록표에서 까닭)
|
||||
}
|
||||
const cls = await shapeAt;
|
||||
if (mine !== seq) return;
|
||||
rows = got;
|
||||
shape = cls;
|
||||
draw();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 ·
|
||||
* `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 ·
|
||||
* `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 ·
|
||||
* stdin = {rows, def?} · stdout = {urls, groups, classes} JSON. */
|
||||
* `classes`(149-1 분류 뼈대) · stdin = {rows, def?, classes?} · stdout = {urls, groups, classes} JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
@@ -45,7 +45,7 @@ global.fetch = async (url) => {
|
||||
await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인
|
||||
await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사
|
||||
await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null);
|
||||
const groups = side.groupRows(input.rows, "미분류").map((g) => ({
|
||||
const groups = side.groupRows(input.rows, "미분류", input.classes ?? []).map((g) => ({
|
||||
구분: g.구분,
|
||||
상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]),
|
||||
}));
|
||||
|
||||
@@ -20,10 +20,10 @@ def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run(rows):
|
||||
def _run(rows, classes=None):
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")],
|
||||
input=json.dumps({"rows": rows}, ensure_ascii=False),
|
||||
input=json.dumps({"rows": rows, "classes": classes}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -86,3 +86,47 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
|
||||
assert "getActiveOwner" not in lib and lib.count("opt.owner") >= 6
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "opt.owner !== undefined" in detail
|
||||
|
||||
|
||||
def test_분류_뼈대_폴더_고정_빈_폴더도_목록_밖_값_뒤_미분류_맨_뒤():
|
||||
"""149-1 — 분류 차례대로 빈 폴더도 · 목록 밖 지금 값은 뒤 · 미분류는 문서 있을 때만 맨 뒤"""
|
||||
classes = [
|
||||
{"name": "A 횡단배수", "details": ["공통", "배관", "BOX암거"]},
|
||||
{"name": "C 사면안정", "details": ["공통", "돌쌓기"]},
|
||||
]
|
||||
rows = [
|
||||
{"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": ""},
|
||||
{"이름": "SC-000002", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"},
|
||||
{"이름": "SC-000003", "종류": "precombo", "구분": "건설품셈 공통", "상세구분": "07장"},
|
||||
{"이름": "SC-000004", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "옛 종류"},
|
||||
]
|
||||
groups = _run(rows, classes)["groups"]
|
||||
assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "건설품셈 공통", "미분류"]
|
||||
assert groups[0]["상세"] == [
|
||||
["공통", []],
|
||||
["배관", []],
|
||||
["BOX암거", []],
|
||||
["옛 종류", ["SC-000004"]],
|
||||
]
|
||||
assert groups[1]["상세"] == [["공통", []], ["돌쌓기", ["SC-000002"]]]
|
||||
assert groups[3]["상세"] == [["", ["SC-000001"]]]
|
||||
# 문서 없으면 미분류 폴더 없음 · 뼈대는 그대로
|
||||
empty = _run([], classes)["groups"]
|
||||
assert [g["구분"] for g in empty] == ["A 횡단배수", "C 사면안정"]
|
||||
|
||||
|
||||
def test_나무_폴더_문서수_처음_펼침_폴더_메뉴_생성():
|
||||
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
|
||||
assert "groupRows(rows, unsorted, shape)" in side
|
||||
assert "fetchStructureDef().then(precomboClasses" in side
|
||||
assert "const count = children.reduce((n, c) => n + docs(c), 0);" in side # 수 = 문서 수
|
||||
assert "open: toggled.get(id) ?? count > 0," in side # 문서 든 폴더만 펼침
|
||||
assert "if (!rows.length && !shape.length)" in side # 뼈대 있으면 「없음」 아님
|
||||
assert 'label: tc("Lib_NewHere")' in side and "run: () => make(...at)" in side
|
||||
assert 'disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly")' in side
|
||||
head = "const make = (구분: string, 상세구분: string): void => {"
|
||||
make = side.split(head)[1].split("\n };")[0]
|
||||
assert "const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] };" in make
|
||||
assert 'newLibrary(at.layer, "precombo", { 문서 }, at.owner)' in make
|
||||
lib = _text("M02_MasterTemplete_Library_List.ts")
|
||||
assert "export const nameDialog = (" in lib
|
||||
|
||||
Reference in New Issue
Block a user