- 새 UI_Side_Sangeun.ts — 서고 칸(716-4) kind sangeun · 목록표 고름 sangeunList - 새 Sangeun_View.ts — 이름 · 단위 · 바탕 · 덮개 · 더한 줄 · 뺀 줄 · 열린 입력 요약 + 상세 자리표(717-2b 가 갈음) - UI_Main 종류 sangeun 갈래 · 목록표 조건에 sangeun · Library_List 열 표(단가산출 = 바탕 · 고친 줄 · 쓰는 곳) · sangeunBlank(단위 식 · 바탕 없음) - UI_Page 단가산출 층 상태 sk · UI_Side 맨 위 칸 · reveal 「단가산출」 · Layer open 「단가산출」 · Api Kind sangeun - m_settings_nav 대시보드 마스터 템플릿 묶음 「단가산출」 줄(?open=단가산출) · locale_m2 키 - 낡은 글 대조 시험 넷 고침(197_1 · b01_settings_cards · m02_layer_screen · m02_precombo_scope) — 대시보드 줄 하나 늘고 OPENS · reveal · 목록 열 표 · 칸 차례가 바뀜 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
16 KiB
TypeScript
373 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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).
|
||
* 서버 길은 계약 `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 { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
|
||
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
|
||
import { canEdit, 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 {
|
||
/** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */
|
||
layer: Layer;
|
||
/** 이 층을 고칠 수 있나 */
|
||
isAdmin: 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 = opt.layer;
|
||
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 (!canEdit(layer, opt.isAdmin)) 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 =
|
||
layer === "system"
|
||
? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))
|
||
: null;
|
||
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
|
||
const practice =
|
||
layer === "system" && opt.isAdmin
|
||
? buildPracticeSection({
|
||
confirmLeave: opt.confirmLeave,
|
||
show: opt.showView,
|
||
onOpen: paint,
|
||
})
|
||
: null;
|
||
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
|
||
const hopyo = buildHopyoSection({
|
||
canWrite: canEdit(layer, opt.isAdmin),
|
||
confirmLeave: opt.confirmLeave,
|
||
show: opt.showView,
|
||
onOpen: () => (practice?.clear(), paint()),
|
||
});
|
||
const listHost = el("div", {
|
||
className: "m02-side__lists ui-sidebar-panel",
|
||
children: [
|
||
sangeunSec,
|
||
precomboSec,
|
||
summary,
|
||
createDivider(),
|
||
section(hopyo.title, hopyo.body),
|
||
...(drawing ? [drawing] : []),
|
||
...(practice ? [section(practice.title, practice.body)] : []),
|
||
],
|
||
});
|
||
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.layer === layer && 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();
|
||
if (opt.getOpen()) practice?.clear();
|
||
}
|
||
|
||
// 여는 것은 편집 칸(`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,
|
||
};
|
||
}
|