feat(M02): 일위대가 라이브러리 화면 — 좌측 「일위대가 조합」 컨테이너 · 뿌리 셋 나무 · 조합 편집 · M01 컨테이너 뺌(PLAN 27-2b · 27-2c 화면)

- 좌측 「일위대가 조합」(구조물 조합 위) → 메인 칸 왼쪽 뿌리 셋 + 조합 나무(폴더 [구분, 상세구분] 깊이 2 · TreePanel 공용) · 오른쪽 목록 · 상세(옛 M01 조합 화면 옮김 · Labor_Edit)
- 나무 오른쪽 클릭: 폴더 더하기(2 단까지) · 조합 더하기(그 폴더 채운 빈 조합) · 이름 · 옮기기 · 지우기 · 조합 이름 바꾸기 서버 길 더함
- 해시 open=일위대가 로 진입 · 다른 뿌리를 누르면 그 뿌리 일위대가로
- 읽기만 뿌리는 저장 · 지우기 · 로직 더하기 없음
- TreePanel 에 maxDepth · item(부르는 말 · 새 항목 화면) 선택지 더함(구조물 라이브러리는 그대로)
- 조합 고르기(면적산출 옆 목록 · 일위대가 고르기)가 이 층이 쓸 수 있는 세 층 조합 · 알약은 /api/m02/combos/key/{키}/inputs
- M01 좌측 · 페이지에서 일위대가 조합 컨테이너 뺌
- 시험 test_m02_labor_tree(새) · test_m02_combo_layers 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-30 13:35:38 +09:00
co-authored by Claude Opus 5.5
parent ca946ebbf1
commit c8686ee3ad
17 changed files with 572 additions and 268 deletions
@@ -4,7 +4,8 @@
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
* · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
* 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움
* · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
* · 조합 목록 — 면적 = 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
* 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로)
* ========================================================================== */
@@ -19,8 +20,8 @@ import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import { fetchCombos } from "./M02_MasterTemplete_Labor_Api";
import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import { getActiveLayer, listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
import "./M02_MasterTemplete_ComboPanel.css";
@@ -177,7 +178,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
const [base, got] = await Promise.all([
readColumnBase(opts.column),
area
? fetchCombos().then((cs) =>
? fetchUsableCombos(getActiveLayer()).then((cs) =>
cs.map((c) => ({
키: c.키,
이름: c.이름,
@@ -216,14 +216,17 @@ def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]:
return one
def _check(doc: dict, one: dict) -> None:
"""이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422."""
def _name_errors(doc: dict, one: dict) -> list[str]:
name = one["이름"]
errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else []
if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]):
errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로")
errors += mcb.check_row(m01.loaded()[1], one)
if errors:
return errors
def _check(doc: dict, one: dict) -> None:
"""이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422."""
if errors := _name_errors(doc, one) + mcb.check_row(m01.loaded()[1], one):
raise store.StoreError(422, {"errors": errors})
@@ -316,7 +319,7 @@ def _fresh(name: Any) -> str:
def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
"""나무 고침 한 번 → (새 표, 지운 조합 키). 조합 더하기 · 고치기는 `create` · `edit` 몫."""
"""나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫."""
op, key = body.get("op"), body.get("키")
folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []]
gone: list[str] = []
@@ -330,6 +333,8 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
if made in folders:
raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」")
folders.append(made)
elif op == "rename" and key:
_find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름"))
elif op == "move" and key:
_set_path(_find({"줄": rows}, str(key)), target)
elif op == "delete" and key:
@@ -360,14 +365,18 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
def edit_tree(body: dict) -> dict[str, Any]:
"""폴더 더하기 · 이름 바꾸기 · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · 조합)
→ 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409."""
"""폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 ·
조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422."""
doc, have = _load()
if have != (body.get("판") or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
new, gone = _apply(doc, body)
_refuse_used(gone)
if heads()[-1] != MASTER:
if body.get("op") == "rename" and body.get("키"):
one = _find(new, str(body["키"]))
if errors := _name_errors(new, one):
raise store.StoreError(422, {"errors": errors})
_save(new, have)
return {**listing(), "지운조합": gone}
was = {str(r["키"]): r for r in doc["줄"]}
@@ -0,0 +1,156 @@
/* =============================================================================
* M02_MasterTemplete_Labor.ts
* 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 누르면 메인 칸에 일위대가 라이브러리:
* 왼쪽 뿌리 셋(마스터 · 회사 · 개인 · `TreePanel_Roots`) 아래 조합 나무(폴더 [구분, 상세구분] 깊이 2 ·
* 구조물 라이브러리와 같은 `TreePanel`) · 오른쪽 조합 목록 · 상세(`Labor_Edit`).
* 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김).
* ========================================================================== */
import { el, showConfirmDialog } from "@ui/ui_template_elements";
import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
editComboTree,
fetchComboList,
type ComboList,
type ComboTreeEdit,
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit";
import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */
function treeError(error: unknown): Error {
if (!(error instanceof ApiError))
return error instanceof Error ? error : new Error(String(error));
const d = error.detail as { errors?: string[]; stale?: unknown } | string | null;
const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`;
return error.status === 409 ? new StaleError(text) : new Error(text);
}
export interface LaborLibraryOptions {
scope: LaborScope;
canEdit: boolean;
/** 뿌리 셋 — 고른 뿌리 아래에 이 나무(`panel`)를 붙인 것 */
roots: (panel: HTMLElement) => HTMLElement;
}
export interface LaborLibrary {
root: HTMLElement;
refresh: () => Promise<void>;
/** 메인 칸 「저장 안 한 고침」 손잡이 */
bind: (setDirty: (dirty: boolean) => void) => void;
}
export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
const { scope } = opt;
let setDirty: (dirty: boolean) => void = () => {};
let keys = new Set<string>(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나
const took = (list: ComboList): ComboList => {
keys = new Set(list.줄.map((c) => c.키));
editor.setList(list.줄);
return list;
};
const editor = createLaborEditor({
scope,
canEdit: opt.canEdit,
onDirty: (dirty) => setDirty(dirty),
onSaved: async (key) => {
await tree.refresh();
tree.select(key);
},
});
const tree = createTreePanel({
layer: scope.layer,
owner: scope.owner,
label: tc("Title"),
canEdit: opt.canEdit,
maxDepth: 2,
item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) },
api: {
fetch: async () => {
return toView(took(await fetchComboList(scope)));
},
edit: async (_layer, edit, 판) => {
const body = { ...edit, 판 } as ComboTreeEdit;
const got = await editComboTree(body, scope).catch((error: unknown) => {
throw treeError(error);
});
return toView(took(got), noteOf(edit, got.지운조합));
},
},
onPick: (item) => void editor.open(item.key),
// 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로)
settle: async () => {
if (!editor.isDirty()) return "clean";
if (!(await showConfirmDialog(tc("BackAsk")))) return false;
editor.showList();
return "changed";
},
// 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록
onChanged: async () => {
const open = editor.current();
if (!open) return;
if (keys.has(open)) await editor.open(open);
else editor.showList();
},
});
const aside = el("aside", {
className: "m02-shell__tree",
children: [
el("h3", { className: "m02-shell__title", text: tc("Library") }),
opt.roots(tree.root),
],
});
return {
root: el("div", { className: "m02-shell m02-labor", children: [aside, editor.root] }),
refresh: () => tree.refresh(),
bind: (fn) => (setDirty = fn),
};
}
export interface LaborSectionOptions {
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
make: () => LaborLibrary;
}
export interface LaborSection {
title: string;
body: HTMLElement;
open: () => Promise<void>;
/** 다른 화면을 열었음 — 고름 표시만 지움 */
clear: () => void;
}
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": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
let library: LaborLibrary | null = null;
let opened = false;
const mark = (on: boolean): void => {
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
if (opened || !(await opt.confirmLeave())) return;
library ??= opt.make();
library.bind(opt.show(tc("Library"), library.root));
opt.onOpen();
mark(true);
await library.refresh();
}
row.addEventListener("click", () => void open());
return { title: tc("Title"), body, open, clear: () => mark(false) };
}
@@ -134,6 +134,38 @@ export const fetchCombos = (scope: LaborScope = SYSTEM_SCOPE): Promise<ComboSumm
/** 세 층 목록 — 고르기(use-combo · 갈라 내기) */
export const fetchAllCombos = (): Promise<ComboLayerList[]> => request("/combos/all");
/** 조합 고르기(PLAN 27-2c) — 이 층 항목이 쓸 수 있는 조합: 마스터 · 회사(회사 · 개인 층) · 개인(개인 층) ·
* 차례 = 마스터 → 회사 → 개인 · `layer` = 그 조합의 층 */
export async function fetchUsableCombos(
layer: Layer,
): Promise<(ComboSummary & { layer: LaborScope["layer"] })[]> {
const usable: LaborScope["layer"][] =
layer === "personal" || layer === "project"
? ["system", "company", "personal"]
: layer === "company"
? ["system", "company"]
: ["system"];
const all = await fetchAllCombos();
return all
.filter((group) => usable.includes(group.layer))
.flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer })));
}
/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */
export interface ComboQuantity {
키: string;
이름: string;
단위: string;
로직: string[];
배?: number;
}
/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */
export const fetchComboInputs = (
key: string,
): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> =>
request(`/combos/key/${enc(key)}/inputs`);
/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */
export const fetchCombo = (key: string): Promise<ComboOne & LaborScope> =>
request<Omit<ComboOne, "file" | "version"> & LaborScope & { 판: string }>(
@@ -0,0 +1,391 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Edit.ts
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b).
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import {
deleteCombo,
editCombo,
fetchCombo,
fetchLibLogics,
newCombo,
type Combo,
type ComboSummary,
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "./M02_MasterTemplete_Labor.css";
const blankCombo = (path: string[] = []): Combo => ({
키: "",
이름: "",
구분: path[0] ?? null,
상세구분: path[1] ?? null,
단위: null,
담은로직: [],
출처: "자체",
소유: "현장",
본뜬키: null,
비고: null,
});
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */
export function reason(error: unknown): string {
if (error instanceof ApiError && typeof error.detail === "object" && error.detail) {
const d = error.detail as { errors?: string[]; stale?: string[] };
// 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림
if (d.errors)
return d.errors
.map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} |U[CP]\d+-\d{6} )?/, ""))
.join(" / ");
if (d.stale) return tc("Stale");
}
return error instanceof Error ? error.message : tc("Load_Failed");
}
export interface LaborEditOptions {
scope: LaborScope;
canEdit: boolean;
/** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */
onSaved: (key: string | null) => Promise<void>;
/** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
onDirty: (dirty: boolean) => void;
}
export interface LaborEditor {
root: HTMLElement;
/** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */
setList: (items: ComboSummary[]) => void;
open: (key: string) => Promise<void>;
/** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */
startNew: (path: string[]) => void;
showList: () => void;
isDirty: () => boolean;
/** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */
current: () => string | null;
}
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
const edit = opt.canEdit;
let items: ComboSummary[] = [];
let logics = new Map<string, LogicSummary>();
/** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */
let nav = 0;
/** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */
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();
if (!current) drawList();
});
const logicsReady = fetchLibLogics()
.then((all) => {
logics = new Map(all.map((x) => [x.키, x]));
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved;
const markDirty = (): void => opt.onDirty(dirty());
/* --- 목록 --- */
const drawList = (): void => {
nav++;
current = null;
opt.onDirty(false);
const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q));
const heads = [
tc("Head_Key"),
tc("Head_Name"),
tc("Sub"),
tc("Detail"),
tc("Head_Unit"),
tc("Head_Count"),
tc("Head_Origin"),
];
const rows = shown.map((x) => {
const tr = el("tr", {
className: "m01-master__table-row",
attrs: { "data-combo-key": x.키 },
children: [
x.키,
x.이름,
x.구분 ?? "",
x.상세구분 ?? "",
x.단위 ?? "",
String(x.count),
x.본뜬키 ?? tc("Origin_Self"),
].map((v) => el("td", { text: v, attrs: { title: v } })),
});
tr.addEventListener("click", () => void open(x.키));
return tr;
});
view.replaceChildren(
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") })]),
],
}),
shown.length
? el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
})
: el("p", { className: "m01-logic__muted", text: tc(items.length ? "NoMatch" : "Empty") }),
);
};
const open = async (key: string): Promise<void> => {
try {
const my = ++nav;
const one = await fetchCombo(key);
await logicsReady;
if (my !== nav) return;
draw(one.combo, one.version);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
};
const startNew = (path: string[]): void => draw(blankCombo(path), "");
/* --- 상세 --- */
const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => {
nav++;
current = { combo, version, saved };
opt.onDirty(dirty());
const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = combo[name] ?? "";
f.input.readOnly = !edit;
f.input.setAttribute("data-combo-field", name);
if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음
f.input.addEventListener("input", () => {
const v = f.input.value;
if (name === "이름") combo.이름 = v;
else combo[name] = v || null;
markDirty();
});
return f.root;
};
const preview = buildPreview(() => combo);
const table = el("div", { className: "m01-master__grid-wrap" });
const drawTable = (): void => {
const move = (i: number, d: number): void => {
const j = i + d;
if (j < 0 || j >= combo.담은로직.length) return;
[combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]];
drawTable();
markDirty();
};
const trs = combo.담은로직.map((row, i) => {
const info = logics.get(row.로직);
const memo = createInputField({ type: "text" });
memo.input.value = row.메모 ?? "";
memo.input.readOnly = !edit;
memo.input.addEventListener("input", () => {
row.메모 = memo.input.value || null;
markDirty();
});
const small = (label: string, run: () => void): HTMLElement =>
createButton({ label, variant: "ghost", size: "sm", onClick: run });
const btns = el("div", {
className: "m01-logic__chips",
children: edit
? [
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
small(tc("Remove"), () => {
combo.담은로직.splice(i, 1);
drawTable();
markDirty();
}),
]
: [],
});
return el("tr", {
attrs: { "data-combo-logic": row.로직 },
children: [
el("td", { text: String(i + 1) }),
el("td", { text: row.로직 }),
el("td", { text: info?.이름 ?? row.로직 }),
el("td", { text: info?.결과단위 ?? "" }),
el("td", { children: [memo.root] }),
el("td", { children: [btns] }),
],
});
});
const heads = [
tc("Col_Order"),
tc("Col_Key"),
tc("Col_Name"),
tc("Col_Unit"),
tc("Col_Memo"),
tc("Col_Move"),
];
table.replaceChildren(
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: trs }),
],
}),
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
);
void preview.redraw();
};
const add = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker((x) => {
if (!logics.has(x.키)) logics.set(x.키, x);
combo.담은로직.push({ 로직: x.키, 메모: null });
drawTable();
markDirty();
}),
});
const save = createButton({
label: tc("Save"),
variant: "filled",
onClick: async () => {
if (!combo.이름.trim()) return showToast(tc("NeedName"), "error");
save.disabled = true; // 연달아 눌러도 한 번만
try {
let key = combo.키;
if (key) await editCombo(key, version, combo, opt.scope);
else key = (await newCombo(combo, opt.scope)).key;
showToast(tc("Saved"), "success");
current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음
opt.onDirty(false);
await opt.onSaved(key);
await open(key);
} catch (error) {
showToast(reason(error), "error");
} finally {
save.disabled = false;
}
},
});
const remove = createButton({
label: tc("Delete"),
variant: "ghost",
onClick: async () => {
if (
!combo.키 ||
!(await showConfirmDialog(`${combo.키} ${combo.이름} — ${tc("DeleteAsk")}`))
)
return;
try {
await deleteCombo(combo.키, version, opt.scope);
showToast(tc("Deleted"), "success");
current = null;
await opt.onSaved(null);
drawList();
} catch (error) {
showToast(reason(error), "error");
}
},
});
const back = createButton({
label: "←",
variant: "ghost",
onClick: async () => {
if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
drawList();
},
});
const origin = createInputField({ type: "text", label: tc("Head_Origin") });
origin.input.value = combo.본뜬키 ?? tc("Origin_Self");
origin.input.readOnly = true;
origin.input.setAttribute("data-combo-origin", combo.본뜬키 ?? "");
view.replaceChildren(
el("div", {
className: "m01-logic__section-head",
children: [
back,
el("h3", { text: combo.키 || tc("New") }),
...(edit ? [remove, save] : []),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01lab__info",
children: [
field(tc("Head_Name"), "이름"),
field(tc("Sub"), "구분"),
field(tc("Detail"), "상세구분"),
field(tc("Head_Unit"), "단위"),
field(tc("Head_Note"), "비고"),
origin.root,
],
}),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])],
}),
table,
],
}),
preview.root,
);
drawTable();
};
view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") }));
return {
root: view,
setList: (next) => {
items = next;
if (!current) drawList();
},
open,
startNew,
showList: drawList,
isDirty: dirty,
current: () => current?.combo.키 ?? null,
};
}
@@ -7,6 +7,10 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 조합", "Unit-price combination"],
Library: ["일위대가 라이브러리", "Unit-price library"],
AddCombo: ["조합 더하기", "Add combination"],
ComboName: ["조합 이름", "Combination name"],
ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"],
Sub: ["구분", "Category"],
Detail: ["상세구분", "Detail"],
All: ["전체", "All"],
@@ -0,0 +1,45 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Tree.ts
* 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림.
* 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키).
* ========================================================================== */
import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch";
import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api";
/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */
export const pathOf = (c: ComboSummary): string[] =>
c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : [];
/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */
export const toView = (list: ComboList, note?: TreeNote): TreeView => ({
판: list.판,
폴더: list.폴더,
items: list.줄.map((c) => ({
key: c.키,
열: c.키,
경로: [...pathOf(c), c.이름],
이름: c.이름,
단위: c.단위,
})),
...(note ? { 알림: note } : {}),
});
/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */
export function noteOf(edit: TreeEdit, gone: string[]): TreeNote {
const at = edit.경로 ?? [];
const last = at[at.length - 1] ?? "";
if (edit.키 && edit.op !== "delete") {
const where = edit.op === "move" ? (edit.새경로 ?? []) : at;
return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone };
}
const path =
edit.op === "add-folder"
? [...at, edit.이름 ?? ""]
: edit.op === "rename"
? [...at.slice(0, -1), edit.이름 ?? ""]
: edit.op === "move"
? [...(edit.새경로 ?? []), last]
: undefined;
return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone };
}
@@ -62,16 +62,27 @@ def get_all(session: Session = Depends(verify_session)) -> list[dict]:
return out
@router.get("/combos/key/{key}")
def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict:
"""키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`."""
def _where(key: str) -> tuple[str, str | None]:
got = combos.where(key)
if got is None:
raise HTTPException(status_code=404, detail=f"없는 조합 「{key}」")
layer, owner = got
return got
@router.get("/combos/key/{key}")
def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict:
"""키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`."""
layer, owner = _where(key)
return {"layer": layer, "owner": owner, **_call(_root(session, layer, owner), combos.one, key)}
@router.get("/combos/key/{key}/inputs")
def get_inputs_by_key(key: str, session: Session = Depends(verify_session)) -> dict:
"""조합 알약(어느 층이든) — 구조물 조합 면적산출 연결이 부름."""
layer, owner = _where(key)
return _call(_root(session, layer, owner), combos.pills, key)
@router.get("/layers/{layer}/combos")
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
return _call(_root(session, layer, owner), combos.listing)
@@ -23,8 +23,8 @@ import {
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import { type ComboSummary, fetchCombos } from "./M02_MasterTemplete_Labor_Api";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import {
copyArea,
copyMaterial,
@@ -86,13 +86,9 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
/** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
export type ColumnNumbers = Record<string, StructureNumbers>;
/** 일위대가 목록 — 처음 한 번만 받음(못 받으면 다음에 다시) */
let combos: Promise<ComboSummary[]> | null = null;
const loadCombos = (): Promise<ComboSummary[]> =>
(combos ??= fetchCombos().catch((error: unknown) => {
combos = null;
throw error;
}));
/** 일위대가 목록 — 이 층이 쓸 수 있는 세 층 조합(27-2c) · 고를 때마다 받음(층을 바꿔 다시 들어와도 맞게) */
const loadCombos = () => fetchUsableCombos(getActiveLayer());
const LAYER_WORD = { system: "마스터", company: "회사", personal: "개인" } as const;
interface Choice {
value: string;
@@ -309,7 +305,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
};
const pickPrice = async (colId: string, columns: StructureColumn[]): Promise<string | null> => {
let list: ComboSummary[];
let list: Awaited<ReturnType<typeof loadCombos>>;
try {
list = await loadCombos();
} catch (error) {
@@ -321,7 +317,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
value: c.키,
key: c.키,
// 이름에 단위가 이미 붙은 조합이 있음 — 겹치면 뺌
label: [c.이름, c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, c.구분]
label: [
c.이름,
c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`,
c.구분,
`· ${LAYER_WORD[c.layer]}`,
]
.filter(Boolean)
.join(" "),
}));
@@ -56,6 +56,10 @@ export interface TreePanelOptions {
};
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
maxDepth?: number;
/** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */
item?: { add: string; name: string; onAdd?: (path: string[]) => void };
/** 항목을 누름 — 표에서 그 열을 고름 */
onPick: (item: TreeItem) => void;
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
@@ -106,8 +110,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return [
...(target.kind === "folder"
? [
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth
? []
: [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]),
{
label: opt.item?.add ?? L("M02_TreeAddItem"),
run: () => void addItem(target.path),
},
]
: []),
...(isTop
@@ -272,6 +281,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
async function addItem(parent: string[]): Promise<void> {
if (!(await ready())) return;
if (opt.item?.onAdd) return opt.item.onAdd(parent);
const got = await askText(L("M02_TreeAddItem"), [
{ key: "name", label: L("M02_TreeItemName") },
{ key: "unit", label: L("M02_TreeUnit") },
@@ -291,7 +301,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const got = await askText(L("M02_TreeRename"), [
{
key: "name",
label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"),
label:
target.kind === "item"
? (opt.item?.name ?? L("M02_TreeItemName"))
: L("M02_TreeFolderName"),
value: nameOf(target),
},
]);
@@ -23,8 +23,10 @@ import {
entryQuery,
layerOfRoot,
ownOwner,
type OpenTarget,
type Root,
} from "./M02_MasterTemplete_Layer";
import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
import { createLibraryRoots } from "./M02_MasterTemplete_TreePanel_Roots";
@@ -55,11 +57,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
settle: () => main.settle(),
onChanged: (colId) => main.openTable(layer, colId, true),
});
const go = async (next: Root, nextOwner: string | null): Promise<void> => {
const go = async (
next: Root,
nextOwner: string | null,
open: OpenTarget = "구조물조합",
): Promise<void> => {
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
if (!(await main.confirmLeave())) return;
navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery({ ...entry, root: next, owner: nextOwner }));
navigateTo(
ROUTES.M02_MASTER_TEMPLATE,
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
);
};
// 일위대가 라이브러리(PLAN 27-2b) — 같은 뿌리 셋 · 다른 뿌리를 누르면 그 뿌리의 일위대가로 다시 엶
const laborRoots = (panel: HTMLElement): HTMLElement =>
createLibraryRoots({
user,
active: { root: entry.root, owner },
panel,
go: (next, nextOwner) => void go(next, nextOwner, "일위대가"),
});
const roots = createLibraryRoots({
user,
active: { root: entry.root, owner },
@@ -97,6 +114,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
confirmLeave: main.confirmLeave,
onOpen,
make: () =>
buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, roots: laborRoots }),
}),
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -116,9 +141,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
root.innerHTML = "";
root.append(layout);
void main.open(home);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만(일위대가는 27-2 컨테이너가 생기면) ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open === "옵션" || entry.open === "도면") side.reveal(entry.open);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 뿌리 ·
// 옵션 · 일위대가 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
@@ -39,6 +39,7 @@ import {
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
import type { LaborSection } from "./M02_MasterTemplete_Labor";
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
@@ -73,6 +74,8 @@ export interface SideOptions {
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */
labor?: (hooks: { onOpen: () => void }) => LaborSection;
}
export interface SideHandle {
@@ -81,7 +84,7 @@ export interface SideHandle {
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */
reveal: (target: "옵션" | "도면") => void;
reveal: (target: "옵션" | "도면" | "일위대가") => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -131,7 +134,7 @@ export function buildSide(opt: SideOptions): SideHandle {
confirmLeave: opt.confirmLeave,
show: opt.showView,
close: () => opt.onOpen(null),
onOpen: () => (options.clear(), paint()),
onOpen: () => (options.clear(), labor?.clear(), paint()),
});
const matsetSection = section(matset.title, matset.body, ...matset.actions);
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
@@ -143,8 +146,9 @@ export function buildSide(opt: SideOptions): SideHandle {
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (matset.clear(), paint()),
onOpen: () => (matset.clear(), labor?.clear(), paint()),
});
const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) });
// 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음)
const summary = section(
L("M02_KindTable"),
@@ -164,7 +168,11 @@ export function buildSide(opt: SideOptions): SideHandle {
: null;
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])],
children: [
...(labor ? [section(labor.title, labor.body)] : []),
summary,
...(extras ? [extras.옵션, extras.도면] : []),
],
});
attachCollapsible(listHost);
@@ -240,6 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle {
paintButtons();
if (opt.getOpen()) matset.clear();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) labor?.clear();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
@@ -363,7 +372,11 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
const reveal = (target: "옵션" | "도면"): void => {
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
if (target === "일위대가") {
labor?.body.closest("section")?.classList.remove("is-collapsed");
return void labor?.open();
}
extras?.[target].classList.remove("is-collapsed");
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
};