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:
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user