Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-09-30 18:16:41 +09:00
14 changed files with 127 additions and 51 deletions
+13 -10
View File
@@ -42,6 +42,8 @@ export interface LaborLibrary {
root: HTMLElement;
/** 좌측 패널 나무 */
tree: HTMLElement;
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
add: HTMLElement;
refresh: () => Promise<void>;
/** 메인 칸 「저장 안 한 고침」 손잡이 */
bind: (setDirty: (dirty: boolean) => void) => void;
@@ -103,9 +105,16 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
else editor.showList();
},
});
// 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름
tree.search
.querySelector("input")
?.addEventListener("input", (event) =>
editor.setQuery((event.target as HTMLInputElement).value),
);
return {
root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }),
tree: tree.root,
add: tree.add,
refresh: () => tree.refresh(),
bind: (fn) => (setDirty = fn),
};
@@ -120,8 +129,8 @@ export interface LaborSectionOptions {
onOpen: () => void;
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
make: (reveal: () => Promise<void>) => LaborLibrary;
/** 줄 아래 층 단추(PLAN 29-2) */
bar?: HTMLElement;
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
bar?: (add: HTMLElement) => HTMLElement;
}
export interface LaborSection {
@@ -132,16 +141,11 @@ export interface LaborSection {
clear: () => void;
}
/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */
export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
text: tc("Library"),
attrs: { type: "button", "data-labor-row": "" },
});
let opened = false;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
@@ -156,8 +160,7 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
void library.refresh();
const body = el("div", {
className: "m02-side__items ui-sidebar-list",
children: [row, ...(opt.bar ? [opt.bar] : []), library.tree],
children: [...(opt.bar ? [opt.bar(library.add)] : []), library.tree],
});
row.addEventListener("click", () => void open());
return { title: tc("Title"), body, open, clear: () => mark(false) };
}
@@ -26,6 +26,7 @@ import {
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "./M02_MasterTemplete_Labor.css";
@@ -77,6 +78,8 @@ export interface LaborEditor {
isDirty: () => boolean;
/** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */
current: () => string | null;
/** 라이브러리 찾기 칸 글 — 목록을 같은 규칙(`matchText`)으로 거름 */
setQuery: (text: string) => void;
}
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
@@ -89,11 +92,11 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
let current: { combo: Combo; version: string; saved: string } | null = null;
let q = "";
const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" });
const find = createInputField({ type: "search", placeholder: tc("Search") });
find.input.addEventListener("input", () => {
q = find.input.value.trim().toLowerCase();
// 찾기 칸은 라이브러리 하나(상세 위 · 나무와 같은 글 · PLAN 29-6) — `setQuery` 로 받음
const setQuery = (text: string): void => {
q = text;
if (!current) drawList();
});
};
const logicsReady = fetchLibLogics()
.then((all) => {
@@ -111,7 +114,7 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
nav++;
current = null;
opt.onDirty(false);
const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q));
const shown = items.filter((x) => matchText([x.키, x.이름, x.구분, x.상세구분, x.단위], q));
const heads = [
tc("Head_Key"),
tc("Head_Name"),
@@ -142,7 +145,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
el("div", {
className: "m01-logic__section-head",
children: [
find.root,
...(edit
? [createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })]
: [el("span", { className: "m01-logic__muted", text: tc("ReadOnly") })]),
@@ -387,5 +389,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
showList: drawList,
isDirty: dirty,
current: () => current?.combo.키 ?? null,
setQuery,
};
}
@@ -64,6 +64,28 @@
font-weight: 600;
}
/* 층 단추 옆 「+」 — 나무 맨 위 폴더 · 항목 더하기(PLAN 29-6) */
.m02-tree__add {
min-width: 1.75rem;
padding: var(--spacing-4) var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-buttons);
background: none;
color: var(--color-text-secondary);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.m02-tree__add:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.m02-tree__add[hidden] {
display: none;
}
.m02-layers__pick {
display: inline-flex;
align-items: center;
@@ -47,8 +47,6 @@ export interface TreePanelOptions {
layer: Layer;
/** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 없으면 자기 층 */
owner?: string | null;
/** 맨 위 줄 글 — 없으면 층 이름 */
label?: string;
/** 나무 읽기 · 고치기 길 — 없으면 구조물 조합 나무(`/layers/{층}/tree`) */
api?: {
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
@@ -73,6 +71,8 @@ export interface TreePanelHandle {
root: HTMLElement;
/** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */
search: HTMLElement;
/** 「+」 — 맨 위 폴더 · 항목 더하기 · 부르는 쪽이 층 단추 옆에 붙임(읽기만 층 = 숨김 · 29-6) */
add: HTMLElement;
refresh: () => Promise<void>;
/** 표에서 고른 열을 나무에 표시(null = 지움) */
select: (colId: string | null) => void;
@@ -84,15 +84,6 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text });
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const layerLabel =
opt.label ??
L(
opt.layer === "company"
? "M02_PickerCompany"
: opt.layer === "personal"
? "M02_PickerPersonal"
: "M02_PickerSystem",
);
const owner = opt.owner ?? null;
const api = opt.api ?? { fetch: fetchTree, edit: editTree };
let view: TreeView | null = null;
@@ -107,6 +98,16 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } });
const root = el("div", { className: "m02-tree", children: [body] });
search.root.classList.add("m02-tree__search");
// 층 단추 옆 「+」 — 맨 위에 폴더 · 항목 더하기(뿌리 줄 오른쪽 클릭을 갈음)
const add = el("button", {
className: "m02-tree__add",
text: "+",
attrs: { type: "button", title: L("M02_TreeAddTop"), "aria-label": L("M02_TreeAddTop") },
});
add.hidden = !opt.canEdit;
add.addEventListener("click", (event) =>
openTreeMenu(event, menuOf({ kind: "folder", path: [] })),
);
/* --- 그리기 --- */
const menuOf = (target: Target): TreeMenuItem[] => {
@@ -158,7 +159,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return node(
{ kind: "folder", path: folder.path },
{
label: folder.path.length ? folder.path[folder.path.length - 1] : layerLabel,
label: folder.path[folder.path.length - 1] ?? "", // 맨 위(빈 경로)는 그리지 않음
count: countItems(folder),
folder: true,
children: kids,
@@ -184,7 +185,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const tree = buildFolders(view, text);
const none = countItems(tree) === 0 && (searching || !tree.folders.length);
body.replaceChildren(
...renderTree([folderNode(tree, searching)], make),
// 맨 위 뿌리 줄 없음 — 폴더 · 항목부터(PLAN 29-6 · 뿌리 메뉴 = 빈 자리 오른쪽 클릭 · 「+」)
...renderTree(folderNode(tree, searching).children ?? [], make),
...(none
? [
hint(
@@ -401,5 +403,5 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
}
});
return { root, search: search.root, refresh, select };
return { root, search: search.root, add, refresh, select };
}
@@ -25,13 +25,18 @@ export const itemIdOf = (item: TreeItem): string => `i|${item.열}`;
export const leafName = (item: TreeItem): string => item.경로[item.경로.length - 1] ?? item.이름;
export const folderOf = (item: TreeItem): string[] => item.경로.slice(0, -1);
/** 찾기 — 경로 · 키 · 일위대가 키에 모든 낱말이 들어 있는 항목(빈 글이면 전부) */
export function matches(item: TreeItem, text: string): boolean {
/** 찾기 규칙 하나(PLAN 29-6) — 띄어쓰기로 나눈 낱말이 모두 들어 있으면 참(AND · 대소문자 무시 · 빈 글 = 참) ·
* 구조물 조합 · 일위대가 라이브러리(나무 · 조합 목록)가 함께 씀 */
export function matchText(parts: (string | null | undefined)[], text: string): boolean {
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hay = [...item.경로, item.key, item.일위대가 ?? ""].join(" ").toLowerCase();
const hay = parts.filter(Boolean).join(" ").toLowerCase();
return words.every((word) => hay.includes(word));
}
/** 나무 항목 찾기 — 이름 · 키 · 경로(폴더 · 구분 · 상세구분) · 단위 · 일위대가 키 */
export const matches = (item: TreeItem, text: string): boolean =>
matchText([...item.경로, item.이름, item.key, item.단위, item.일위대가], text);
/** 폴더 나무 — 찾는 글이 있으면 맞는 항목과 그 위 폴더만 · 없으면 빈 폴더까지 전부 */
export function buildFolders(view: TreeView, text = ""): FolderNode {
const searching = text.trim() !== "";
@@ -28,6 +28,8 @@ export interface LayerBarOptions {
company: string | null;
/** 다른 층을 누름 — `owner` null = 자기 층 */
go: (root: Root, owner: string | null) => void;
/** 단추 옆 「+」(고른 층 나무 맨 위 폴더 · 항목 더하기 · PLAN 29-6) */
add?: HTMLElement;
}
/** 회사 단추의 주인 — 볼 회사가 자기 회사면 null(자기 층) */
@@ -71,7 +73,10 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement {
...(opt.company ? [button("company", companyOwner(opt.company, user))] : []),
...(ownPersonal ? [button("personal", null)] : []),
];
const row = el("div", { className: "m02-layers__row", children: buttons });
const row = el("div", {
className: "m02-layers__row",
children: [...buttons, ...(opt.add ? [opt.add] : [])],
});
const bar = el("div", { className: "m02-layers", children: [row] });
if (isSystem && opt.company) row.append(buildPeople(opt)); // 같은 줄 · 시스템 관리자만
return bar;
@@ -76,12 +76,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
);
};
// 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩
const layerBar = (open: OpenTarget): HTMLElement =>
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
const layerBar = (open: OpenTarget, add: HTMLElement): HTMLElement =>
createLayerBar({
user,
active: { root: entry.root, owner },
company,
go: (next, nextOwner) => void go(next, nextOwner, open),
add,
});
const main = createMain(
isAdmin,
@@ -114,7 +116,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
tree: [layerBar("구조물조합"), tree.root],
tree: [layerBar("구조물조합", tree.add), tree.root],
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
@@ -123,7 +125,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
onOpen();
mark("일위대가");
},
bar: layerBar("일위대가"),
bar: (add) => layerBar("일위대가", add),
make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }),
}),
});
@@ -151,16 +151,26 @@ export function buildSide(opt: SideOptions): SideHandle {
onOpen: () => (matset.clear(), labor?.clear(), paint()),
});
const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) });
// 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음)
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (sec.classList.contains("is-collapsed")) run();
});
return sec;
};
// 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
const summary = section(
L("M02_KindTable"),
bodies.get("table")!,
...(opt.tree ?? []),
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
matsetSection,
);
summary.classList.remove("is-collapsed");
summary.classList.add("m02-side__summary");
openOnExpand(summary, async () => {
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
});
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
const extras =
layer === "system"
@@ -172,7 +182,7 @@ export function buildSide(opt: SideOptions): SideHandle {
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
...(labor ? [section(labor.title, labor.body)] : []),
...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []),
summary,
...(extras ? [extras.옵션, extras.도면] : []),
],
@@ -50,5 +50,14 @@ process.stdout.write(
leaf: m.leafName(view.items[0]),
dir: m.folderOf(view.items[0]),
byKey: m.buildFolders(view, "ss000003").items.length + m.buildFolders(view, "SS000003").folders.length,
// 찾기 AND(PLAN 29-6) — 낱말 모두 · 대소문자 무시 · 순서 무관 · 빈 글 = 참
and: [
m.matchText(["돌기슭막이", "메쌓기", "SS000001", "m"], "메쌓기 ss000001"),
m.matchText(["돌기슭막이", "메쌓기", "SS000001", "m"], "ss000001 돌기슭"),
m.matchText(["돌기슭막이", "메쌓기", "SS000001", "m"], "메쌓기 찰쌓기"),
m.matchText(["UC3-000001", "브레인 측구", null, "배수"], ""),
m.matchText(["UC3-000001", "브레인 측구", null, "배수"], "측구 배수"),
],
andTree: m.countItems(m.buildFolders(view, "돌기슭막이 찰쌓기")),
}),
);
+5 -2
View File
@@ -91,11 +91,14 @@ def test_참조표는_열_머리를_못_고침():
def test_나무는_좌측_패널_찾기_칸은_상세_위쪽():
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert "tree?: HTMLElement[];" in side and "...(opt.tree ?? [])," in side
assert "tree?: HTMLElement[];" in side and "children: opt.tree ?? []" in side
assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "createTreePanel({" in page and "tree.select(column)" in page
assert 'tree: [layerBar("구조물조합"), tree.root],' in page and "search: tree.search," in page
assert (
'tree: [layerBar("구조물조합", tree.add), tree.root],' in page
and "search: tree.search," in page
)
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main
assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목
+2 -2
View File
@@ -83,8 +83,8 @@ def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
assert (
'bar: layerBar("일위대가"),' in page
and 'tree: [layerBar("구조물조합"), tree.root],' in page
'bar: (add) => layerBar("일위대가", add),' in page
and 'tree: [layerBar("구조물조합", tree.add), tree.root],' in page
)
labor = page.split("buildLaborSection({")[1].split("make:")[0]
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
@@ -122,7 +122,8 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
)
# 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식
order = [
"...(labor ? [section(labor.title, labor.body)] : []),",
"...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]"
" : []),",
" summary,",
" ...(extras ? [extras.옵션, extras.도면] : []),",
]
+8 -1
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
"""M02 관리 화면 구조물 라이브러리(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록.
"""M02 구조물 라이브러리(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 ·
폴더 목록.
항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만.
Node 헬퍼(`helper_m02_treepanel.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
@@ -58,6 +59,12 @@ def test_키로도_찾음(results):
assert results["byKey"] == 1
def test_찾기는_낱말_모두_AND(results):
"""PLAN 29-6 — 띄어쓰기로 나눈 낱말을 모두 담은 것만 · 대소문자 무시 · 빈 글 = 전부."""
assert results["and"] == [True, True, False, True, True]
assert results["andTree"] == 1 # 「돌기슭막이 찰쌓기」 = c2 하나
def test_폴더_목록과_위_길(results):
assert ["다섯", "둘", "셋", "넷"] in results["folders"]
assert ["빈 폴더"] in results["folders"]
+8 -4
View File
@@ -104,11 +104,15 @@ export const ui_locales_m2 = {
M02_RootReadOnly: ["읽기", "read-only"],
M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"],
M02_PeoplePick: ["사람 고르기", "Pick a person"],
M02_TreeSearch: ["항목 찾기", "Find item"],
M02_TreeEmptyEdit: [
"항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",
"No items — right-click the top row to add a folder or item",
M02_TreeSearch: [
"찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)",
"Find — name, key, folder, unit (all words)",
],
M02_TreeEmptyEdit: [
"항목이 없음 — 빈 자리 오른쪽 클릭 또는 층 단추 옆 「+」 로 폴더 · 항목을 더함",
"No items — right-click an empty area or use + beside the layer buttons to add",
],
M02_TreeAddTop: ["맨 위에 폴더 · 항목 더하기", "Add folder or item at top"],
M02_TreeEmpty: ["항목이 없음", "No items"],
M02_TreeNoHit: ["찾은 항목이 없음", "No matching item"],
M02_TreeAddFolder: ["폴더 더하기", "Add folder"],