Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
T
eomsangdonandClaude Sonnet 5.5 e1b17a523c feat(732-1): M02 실무문서 칸 · 복사 탭 · 서버 길 걷음
- 왼쪽 「실무문서」 칸(Practice.ts) · 일위대가 가져오기 「실무문서」 탭 · 복사 원본 고르기 · 낱 CSS · locale 열쇠 걷음
- 서버 `from_layer=practice`(_copy_practice) · `DELETE /api/m02/practice`(drop_practice) · 조합 목록 · 키 하나 읽기 길 걷음 · 조합 길 시스템 관리자 문 걷음(담을 로직 읽기는 그대로)
- 옛 마스터 조합 파일 `일위대가조합.json`(UA 37) = 쓰는 곳 있음(구조물집계표 일위대가 칸 · labor 본뜬키 · 프로젝트 초기값 사본 · 불러오기 맞춤 씨앗) → 파일 · 읽기 길 유지 · 지우지 않음
- 낡은 시험: test_m02_practice 지움(화면 · 길 없음) · 조합 길 읽던 시험(combo · combo_inputs · combo_layers)은 저장소 함수로 · 실무문서 복사 시험(labor_copy · pair_copy) 줄 지움 · 탭 · 차례 단정(layer_screen · structure_options · structure_panel · 720 · precombo_scope · ui_loading) 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:17:24 +09:00

358 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteTemplate,
listTemplates,
readTemplate,
saveTemplate,
StaleError,
type Kind,
type Layer,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { createEmpty } from "@ui/ui_template_empty";
import { createDivider } from "@ui/ui_template_divider";
const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
const STUB_DRAWING = {
format: 6,
entities: [],
// 기본 도면층 하나 — 없으면 B07 CAD 가 못 엶("Cannot read properties of
// undefined (reading 'length')", 2026-09-27 실측)
layers: [{ id: "기본", name: "기본", isVisible: true, isLocked: false }],
};
export interface SideOptions {
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음)을 늘 보임(720-4) */
isSystem: boolean;
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */
canWrite: () => boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
tree?: HTMLElement[];
/** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
/** 「단가산출」 층(717-2a · 일위대가 층과 따로 · 층 단추는 페이지가 만듦) */
sangeun: Pick<SangeunOptions, "scope" | "bar">;
}
export interface SideHandle {
root: HTMLElement;
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */
reveal: (target: "옵션" | "도면" | "일위대가조합" | "단가산출") => void;
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
refreshPrecombo: () => Promise<void>;
/** 단가산출 층이 바뀜(717-2a) — 그 컨테이너만 다시 그림 */
refreshSangeun: () => Promise<void>;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
const badName = (name: string): boolean =>
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) ||
name.startsWith(".") ||
name.startsWith("_") ||
name.length > 80;
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
export function buildSide(opt: SideOptions): SideHandle {
const layer: Layer = "system"; // 도면 양식 목록 · 고름의 층
let items: TemplateInfo[] = [];
let seq = 0;
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
el("section", {
className: "m02-side__group ui-collapsible ui-sidebar-section ui-sidebar-card is-collapsed",
children: [el("h3", { className: "ui-collapsible__title", text: title }), body, ...more],
});
const bodies = new Map<Kind, HTMLElement>(
KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]),
);
/** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const actionRow = (kind: Kind): HTMLElement[] => {
if (!opt.isSystem) return [];
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const copy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
editButtons.set(kind, { copy, del });
btnNew.addEventListener("click", () => newTemplate(kind));
copy.addEventListener("click", copyTemplate);
del.addEventListener("click", () => void deleteCurrent());
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
};
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(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"),
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
);
openOnExpand(summary, async () => {
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
});
// 일위대가 서고(50-3 · 134-3) — 단가산출 다음 칸 · 층은 페이지 층과 따로
const precombo = buildPrecomboSection({
...opt.precombo,
confirmLeave: opt.confirmLeave,
onOpen: opt.onOpen,
getOpen: opt.getOpen,
});
const precomboSec = section(L("M02_PrecomboTitle"), precombo.body);
// 단가산출 서고(717-2a) — 맨 위 칸 · 층은 일위대가 층과 따로
const sangeun = buildSangeunSection({
...opt.sangeun,
confirmLeave: opt.confirmLeave,
onOpen: opt.onOpen,
getOpen: opt.getOpen,
});
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
const onBuilt = (): void =>
precombo.body.isConnected
? void Promise.all([precombo.refresh(), sangeun.refresh()])
: document.removeEventListener(HOPYO_BUILT, onBuilt);
document.addEventListener(HOPYO_BUILT, onBuilt);
// 도면 양식 칸 = 누구나 늘(읽기 · 고르기) · 새로 · 본떠 · 지우기 단추만 시스템 관리자
const drawing = section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"));
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: opt.canWrite,
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: paint,
});
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
sangeunSec,
precomboSec,
summary,
createDivider(),
section(hopyo.title, hopyo.body),
drawing,
],
});
attachCollapsible(listHost);
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
/** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐(도면 양식 = 시스템 층뿐 · 구조물 조합 표 = 어느 층이든) */
const isPicked = (kind: Kind, name: string): boolean => {
const cur = opt.getOpen();
return !!cur && cur.kind === kind && cur.name === name;
};
const isOpen = (info: TemplateInfo): boolean => isPicked(info.종류, info.이름);
const currentInfo = (): TemplateInfo | undefined => {
const cur = opt.getOpen();
return cur ? items.find((i) => i.종류 === cur.kind && i.이름 === cur.name) : undefined;
};
function paintList(): void {
for (const [kind, body] of bodies) {
// 표는 구조물집계표 한 줄만(다른 표 양식은 안 보임)
const mine = items.filter(
(i) => i.종류 === kind && (kind !== "table" || i.이름 === STRUCTURE_TABLE),
);
body.replaceChildren(
...(mine.length
? mine.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`,
// 구조물집계표 = 화면 이름 「구조물 조합」(26-7a)
text: info.이름 === STRUCTURE_TABLE ? L("M02_KindTable") : info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => void select(info));
return b;
})
: [createEmpty({ text: L("M02_NoTemplates"), small: true })]),
);
}
}
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
const cur = currentInfo();
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
/** 고름 표시는 여기서만 */
function paint(): void {
paintList();
sangeun.paint();
precombo.paint();
hopyo.paint();
paintButtons();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
async function select(info: TemplateInfo): Promise<void> {
if (!isOpen(info) && !(await opt.confirmLeave())) return;
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
}
async function refresh(): Promise<void> {
const mine = ++seq;
paintButtons();
try {
items = await listTemplates(layer, null); // 도면 양식 목록 — 누구나
} catch (error) {
items = [];
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
if (mine !== seq) return;
paintList();
paintButtons();
await Promise.all([precombo.refresh(), sangeun.refresh()]);
}
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
const nameDialog = (title: string, onOk: (name: string) => Promise<void>): void => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
openModal({
title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
ok.addEventListener("click", async () => {
const text = name.input.value.trim();
if (!text) return showToast(L("M02_NameNeeded"), "error");
if (badName(text)) return showToast(L("M02_BadName"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(text);
close();
} catch (error) {
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
else failed(error);
} finally {
ok.disabled = false;
}
});
name.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) ok.click();
});
body.append(name.root, ok);
},
});
};
const openCreated = async (kind: Kind, name: string): Promise<void> => {
showToast(L("M02_Created"), "success");
await refresh();
const info = items.find((i) => i.종류 === kind && i.이름 === name);
if (info) await select(info);
};
/** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */
function newTemplate(kind: Kind): void {
nameDialog(L("M02_NewTitle"), async (name) => {
await saveTemplate("system", kind, name, null, "", STUB_DRAWING);
await openCreated(kind, name);
});
}
function copyTemplate(): void {
const from = currentInfo();
if (!from) return;
nameDialog(L("M02_CopyTitle"), async (name) => {
const got = await readTemplate("system", from.종류, from.이름, null);
await saveTemplate("system", from.종류, name, null, "", got.문서);
await openCreated(from.종류, name);
});
}
async function deleteCurrent(): Promise<void> {
await refresh(); // 저장 직후여도 최신 판으로
const info = currentInfo();
if (!info) return;
if (info.쓰는곳) {
showToast(
L("M02_InUse").replace("{value}", info.이름).replace("{use}", info.쓰는곳),
"error",
);
return;
}
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
try {
await deleteTemplate(info.종류, info.이름, info.판);
showToast(L("M02_Deleted"), "success");
opt.onOpen(null);
await refresh();
} catch (error) {
failed(error);
}
}
const reveal = (target: "옵션" | "도면" | "일위대가조합" | "단가산출"): void => {
const sec =
target === "단가산출"
? sangeunSec
: target === "일위대가조합"
? precomboSec
: target === "도면"
? drawing
: null;
sec?.classList.remove("is-collapsed");
};
void refresh();
return {
root,
refresh,
paint,
reveal,
refreshPrecombo: precombo.refresh,
refreshSangeun: sangeun.refresh,
};
}