- 공용 부품 ui_template_loading(showLoading · loadingError · delayedShow) — 칸 안 작은 회전 표시 + 「불러오는 중...」 · 화면 전체 잠금 아님 · 다른 페이지도 씀 - 붙인 자리 — 좌측 나무(구조물 · 일위대가 · 사람 나무) · 상세 칸(구조물 표 · 도면 · 면적 · 재료) · 조합 목록 · 조합 상세 · 항목 카드 - 공용 글 Common_Load_Failed · Common_Reload - 시험 test_ui_loading(0.3초 규칙 가짜 시계 · 실패 글 · 붙은 자리) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
9.3 KiB
TypeScript
223 lines
9.3 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Labor.ts
|
|
* 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추
|
|
* (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` ·
|
|
* 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`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,
|
|
editComboTreeMany,
|
|
fetchComboList,
|
|
pasteCombos,
|
|
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;
|
|
/** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */
|
|
copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[];
|
|
/** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */
|
|
reveal: () => Promise<void>;
|
|
}
|
|
|
|
export interface LaborLibrary {
|
|
/** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */
|
|
root: HTMLElement;
|
|
/** 좌측 패널 나무 */
|
|
tree: HTMLElement;
|
|
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
|
|
add: HTMLElement;
|
|
/** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */
|
|
back: () => Promise<void>;
|
|
refresh: () => Promise<void>;
|
|
/** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */
|
|
tools: HTMLElement | null;
|
|
/** 메인 칸 「저장 안 한 고침」 손잡이 */
|
|
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,
|
|
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.지운조합));
|
|
},
|
|
// 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장
|
|
many: async (_layer, body, 판) => {
|
|
const ops = body.대상.map((ref) => ({
|
|
op: body.op,
|
|
...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }),
|
|
...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}),
|
|
}));
|
|
const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => {
|
|
throw treeError(error);
|
|
});
|
|
return toView(took(got), { op: body.op, 지운항목: got.지운조합 });
|
|
},
|
|
// 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로
|
|
paste: async (layer, owner, clip, dest) => {
|
|
const to = { layer, owner } as LaborScope;
|
|
const pick = {
|
|
키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])),
|
|
폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])),
|
|
};
|
|
const got = await fetchComboList(to)
|
|
.then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to))
|
|
.catch((error: unknown) => {
|
|
throw treeError(error);
|
|
});
|
|
const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null);
|
|
return toView(took(here ? got : await fetchComboList(scope)));
|
|
},
|
|
},
|
|
clipKind: "일위대가",
|
|
copyTo: opt.copyTo,
|
|
onPick: async (item) => {
|
|
await opt.reveal();
|
|
tree.select(item.key);
|
|
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();
|
|
},
|
|
});
|
|
// 찾기 칸 하나(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,
|
|
back: async () => {
|
|
await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움
|
|
if (editor.current() === null) return;
|
|
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
|
|
editor.showList();
|
|
},
|
|
refresh: async () => {
|
|
const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6)
|
|
try {
|
|
await tree.refresh();
|
|
} finally {
|
|
stop();
|
|
}
|
|
},
|
|
tools: editor.newButton,
|
|
bind: (fn) => (setDirty = fn),
|
|
};
|
|
}
|
|
|
|
export interface LaborSectionOptions {
|
|
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
|
show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void;
|
|
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
|
confirmLeave: () => Promise<boolean>;
|
|
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
|
onOpen: () => void;
|
|
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
|
|
make: (reveal: () => Promise<void>) => LaborLibrary;
|
|
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
|
|
bar?: (add: HTMLElement, back: () => void) => HTMLElement;
|
|
/** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */
|
|
wrap?: (tree: HTMLElement) => HTMLElement;
|
|
}
|
|
|
|
export interface LaborSection {
|
|
title: string;
|
|
body: HTMLElement;
|
|
open: () => Promise<void>;
|
|
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
|
clear: () => void;
|
|
}
|
|
|
|
/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */
|
|
export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
|
|
let opened = false;
|
|
const mark = (on: boolean): void => {
|
|
opened = on;
|
|
};
|
|
|
|
async function open(): Promise<void> {
|
|
if (opened || !(await opt.confirmLeave())) return;
|
|
library.bind(opt.show(tc("Library"), library.root, library.tools));
|
|
opt.onOpen();
|
|
mark(true);
|
|
await library.refresh();
|
|
}
|
|
// 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음
|
|
const library = opt.make(open);
|
|
void library.refresh();
|
|
const body = el("div", {
|
|
className: "m02-side__items ui-sidebar-list",
|
|
children: [
|
|
...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []),
|
|
opt.wrap ? opt.wrap(library.tree) : library.tree,
|
|
],
|
|
});
|
|
return { title: tc("Title"), body, open, clear: () => mark(false) };
|
|
}
|