Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts
T
eomsangdonandClaude Opus 5.5 b1a5f38a16 feat(M02): 50-3③ 일위대가 조합 서고 화면 틀 — 좌측 컨테이너 · 단가승격 · 사전조합 목록 · 상세 서고 모드
- 구조물 조합 아래 「일위대가 조합」 컨테이너 · 층 단추(진입 open=일위대가조합) · 새로 · 복사 · 지우기
- 단가승격 새로 = 로직 고르기 → SP- 키 저장 · 사전조합 새로 = 이름만 → SC- 키
- 상세 = 일위대가 상세 서고 모드(이름 칸 · 로직 표 · 원문만 · 자재 · 면적 · 다이어그램 없음)
- 낡은 글 찾기 시험 셋을 지금 코드에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 18:42:50 +09:00

241 lines
9.9 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_UI_Side_Precombo.ts
* 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
* 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) ·
* 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기.
* 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음).
* 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteLayerTemplate,
deleteTemplate,
getActiveOwner,
readTemplate,
saveTemplate,
type Kind,
type Layer,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
/** 종류 · 키 머리 · 화면 이름(= 문서 `종류` 글) */
const PARTS: { kind: PrecomboKind; prefix: string; label: string }[] = [
{ kind: "promote", prefix: "SP", label: "단가승격" },
{ kind: "precombo", prefix: "SC", label: "사전조합" },
];
export const PRECOMBO_TITLE = "일위대가 조합";
export const isPrecombo = (kind: Kind): kind is PrecomboKind =>
kind === "promote" || kind === "precombo";
/** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */
type Info = TemplateInfo & { 제목?: string };
export const precomboLabel = (info: Info): string =>
info.제목 ? `${info.이름} ${info.제목}` : info.이름;
/** 그 층 다음 키 — 지금 목록의 가장 큰 수 + 1(서버가 같은 키면 409 로 막음) */
const nextKey = (prefix: string, items: Info[]): string => {
const top = items.reduce((max, i) => {
const m = new RegExp(`^${prefix}-(\\d+)$`).exec(i.이름);
return m ? Math.max(max, Number(m[1])) : max;
}, 0);
return `${prefix}-${String(top + 1).padStart(6, "0")}`;
};
export interface PrecomboOptions {
layer: Layer;
canEdit: boolean;
/** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */
bar: HTMLElement[];
confirmLeave: () => Promise<boolean>;
onOpen: (sel: Selection | null) => void;
isOpen: (kind: Kind, name: string) => boolean;
/** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */
refresh: () => Promise<void>;
}
export interface PrecomboHandle {
body: HTMLElement;
/** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */
paint: (items: TemplateInfo[]) => void;
}
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 buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
let items: Info[] = [];
const lists = new Map<PrecomboKind, HTMLElement>();
const buttons = new Map<PrecomboKind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind);
const current = (kind: PrecomboKind): Info | undefined =>
mine(kind).find((i) => opt.isOpen(kind, i.이름));
const select = async (info: Info): Promise<void> => {
if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return;
opt.onOpen({
layer: opt.layer,
projectId: null,
kind: info.종류,
name: info.이름,
label: precomboLabel(info),
});
};
/** 새로 · 복사 — 다음 키로 저장 후 엶 */
const create = async (kind: PrecomboKind, doc: Record<string, unknown>): Promise<void> => {
const key = nextKey(PARTS.find((p) => p.kind === kind)!.prefix, mine(kind));
await saveTemplate(opt.layer, kind, key, null, "", doc);
showToast(L("M02_Created"), "success");
await opt.refresh();
const info = mine(kind).find((i) => i.이름 === key);
if (info) await select(info);
};
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
name.input.value = start;
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");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(text);
close();
} catch (error) {
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 newPromote = (): void =>
openLogicPicker((x) => {
void opt.confirmLeave().then((go) =>
!go
? undefined
: create("promote", {
종류: "단가승격",
이름: x.이름,
단위: x.결과단위 || null,
담은로직: [{ 로직: x.키, 입력값: {} }],
}).catch(failed),
);
});
/** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */
const newPrecombo = (): void =>
nameDialog(L("M02_NewTitle"), "", async (name) => {
if (!(await opt.confirmLeave())) return;
await create("precombo", { 종류: "사전조합", 이름: name, 단위: null, 담은로직: [] });
});
/** 복사 = 같은 층 새 키 · 이름 「(2)」 · 본뜬 = 원래 키 */
const copy = (kind: PrecomboKind): void => {
const from = current(kind);
if (!from) return;
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
if (!(await opt.confirmLeave())) return;
const got = await readTemplate(opt.layer, kind, from.이름, null);
const doc: Record<string, unknown> = { ...(got.문서 as object), 이름: name };
doc.본뜬 = { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 };
delete doc.판;
await create(kind, doc);
});
};
const remove = async (kind: PrecomboKind): Promise<void> => {
await opt.refresh(); // 저장 직후여도 최신 판으로
const info = current(kind);
if (!info) return;
const name = precomboLabel(info);
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return;
try {
if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판);
else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판);
showToast(L("M02_Deleted"), "success");
opt.onOpen(null);
await opt.refresh();
} catch (error) {
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
}
};
const block = (part: (typeof PARTS)[number]): HTMLElement => {
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
lists.set(part.kind, list);
const row: HTMLElement[] = [];
if (opt.canEdit) {
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
buttons.set(part.kind, { copy: btnCopy, del });
btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo);
btnCopy.addEventListener("click", () => copy(part.kind));
del.addEventListener("click", () => void remove(part.kind));
row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] }));
}
return el("div", {
className: "m02-side__sub",
attrs: { "data-precombo": part.kind },
children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list],
});
};
const body = el("div", {
className: "m02-side__items ui-sidebar-list",
children: [...opt.bar, ...PARTS.map(block)],
});
function paint(all: TemplateInfo[]): void {
items = all.filter((i) => isPrecombo(i.종류));
for (const part of PARTS) {
const found = mine(part.kind);
lists.get(part.kind)?.replaceChildren(
...(found.length
? found.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`,
text: precomboLabel(info),
attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 },
});
b.addEventListener("click", () => void select(info));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
const b = buttons.get(part.kind);
if (b) b.copy.disabled = b.del.disabled = !current(part.kind);
}
}
return { body, paint };
}