/* ============================================================================= * M02_MasterTemplete_Combo.ts * 면적산출 편집 화면 — 알약 막대 위 조합 줄 + [조합 고치기](시스템 관리자만). * 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어 * 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양. * 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀. * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { fetchCombo, type ComboLine } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; import { openLogicPicker } from "../M01_MasterData/M01_MasterData_UI_Combo_Pick"; import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_Combo.css"; const TEXT = { combo: "일위대가", logics: "담은 로직 {n}", shared: "다른 열 {n} 이 같이 씀", alone: "이 열만 씀", branched: "본뜬 조합 {value}", edit: "조합 고치기", editTitle: "일위대가 조합 고치기 — {value}", no: "차례", key: "키", name: "이름", unit: "결과 단위", memo: "메모", up: "위", down: "아래", remove: "빼기", add: "로직 더하기", apply: "적용", close: "닫기", empty: "담은 로직이 없음", unchanged: "고친 것이 없음", discard: "고친 것을 버리고 닫음", discardOk: "버리고 닫음", nameEmpty: "이름을 넣음", noLines: "담은 로직이 없어 적용 안 됨 — 로직을 하나 이상 담음", nameLong: "이름은 {n}자까지", loadFailed: "조합을 못 읽음 — {value}", askTitle: "새 조합으로 갈라 냄", askName: "새 조합 이름", askOk: "새로 만듦", askCancel: "취소", askBody: "이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로", askAlone: "이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로", made: "새 조합 {value} 을 만들고 이 열이 가리킴", reused: "같은 내용의 조합 {value} 을 씀", failed: "조합을 못 고침 — {value}", }; /** 조합 이름 글자 수 한도 — 서버 `master_combo.MAX_이름` 과 같음 */ const MAX_NAME = 60; const fill = (text: string, value: string | number): string => text.replace("{n}", String(value)).replace("{value}", String(value)); /** 갈라 내기 확인 모달 — 이름 칸을 미리 채워 두고 고칠 수 있음 · 취소면 null */ export function askBranch(message: string, defaultName: string): Promise { return new Promise((resolve) => { let done = false; const finish = (value: string | null): void => { if (!done) resolve(value); done = true; }; openModal({ title: TEXT.askTitle, closeLabel: TEXT.askCancel, dialogClass: "m02-modal m02-branch", mount: (body, close) => { const name = createInputField({ label: TEXT.askName, type: "text" }); name.input.value = defaultName.slice(0, MAX_NAME); name.input.maxLength = MAX_NAME; const ok = createButton({ label: TEXT.askOk, variant: "filled", onClick: () => { const value = name.input.value.trim(); if (!value || value.length > MAX_NAME) { name.setError(value ? fill(TEXT.nameLong, MAX_NAME) : TEXT.nameEmpty); return name.input.focus(); } finish(value); close(); }, }); name.input.addEventListener("input", () => name.setError(undefined)); name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok.click()); body.append( el("p", { className: "m02-branch__msg", text: message }), name.root, el("div", { className: "m02-branch__foot", children: [ok] }), ); name.input.focus(); }, }); // 닫기 단추 · Esc · 바깥 누르기 — 창이 빠지면 취소 const watch = new MutationObserver(() => { if (document.querySelector(".m02-branch")) return; watch.disconnect(); finish(null); }); watch.observe(document.body, { childList: true }); }); } /** 새 조합 이름 자동 — 원래 이름(끝 「 (n)」 뗌 · 서버가 새로 붙임) · 구조물 이름(한쪽이 다른 쪽을 * 이미 품으면 긴 쪽 하나만) */ export const branchName = ( named: string, label: string, original = named.replace(/ \(\d+\)$/, ""), ): string => !original || label.includes(original) ? label || original : !label || original.includes(label) ? original : `${original} · ${label}`; /** 결과 알림 — 새로 만듦 / 같은 조합 씀 */ export const branchToast = (r: BranchedCombo): void => showToast(fill(r.새로만듦 ? TEXT.made : TEXT.reused, `${r.키} ${r.이름}`), "success"); export interface ComboBarOptions { /** 집계표 열 id */ column: string; /** 화면 제목에 쓰는 구조물 이름(집계표 머리 층을 이은 글) — 새 이름 자동 미리 채움에 씀 */ label: string; isAdmin: boolean; /** 고치기 전 — 저장 안 한 시트 고침이 있으면 물음 · false 면 그만둠 */ beforeEdit: () => Promise; /** 갈라 낸 뒤 — 알약 다시 받기 */ onApplied: () => Promise | void; } interface Line { 로직: string; 메모: string | null; 이름: string; 결과단위: string; } /** 조합 줄 — 이 열에 일위대가가 없으면 null */ export async function buildComboBar(opts: ComboBarOptions): Promise { const base = await readColumnBase(opts.column); if (!base.일위대가) return null; const key = base.일위대가; let one; try { one = await fetchCombo(key); } catch (error) { showToast(fill(TEXT.loadFailed, reasonOf(error)), "error"); return null; } const combo = one.combo as typeof one.combo & { 본뜬키?: string | null }; const same = base.다른열일위대가; const parts = [ el("span", { className: "m02-combo__key", text: `${TEXT.combo} ${key}` }), el("span", { className: "m02-combo__name", text: combo.이름 }), el("span", { className: "m02-combo__meta", text: fill(TEXT.logics, combo.담은로직.length) }), el("span", { className: "m02-combo__meta", text: same ? fill(TEXT.shared, same) : TEXT.alone, }), ]; if (opts.isAdmin && combo.본뜬키) { parts.push( el("span", { className: "m02-combo__origin", text: fill(TEXT.branched, combo.본뜬키) }), ); } const bar = el("div", { className: "m02-combo", children: parts }); if (!opts.isAdmin) return bar; const edit = createButton({ label: TEXT.edit, variant: "ghost", size: "sm" }); edit.addEventListener("click", async () => { if (!(await opts.beforeEdit())) return; openEditor(one.줄, combo.이름, same); }); bar.append(edit); return bar; function openEditor(from: ComboLine[], comboName: string, others: number): void { const original = JSON.stringify(from.map((l) => [l.로직, l.메모 ?? null])); const changed = (): boolean => JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) !== original; const lines: Line[] = from.map((l) => ({ 로직: l.로직, 메모: l.메모, 이름: l.이름 ?? "", 결과단위: l.결과단위 ?? "", })); openModal({ title: fill(TEXT.editTitle, opts.label), closeLabel: TEXT.close, dialogClass: "m02-modal m02-combo-edit", // 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음 beforeClose: () => !changed() || showConfirmDialog(TEXT.discard, TEXT.discardOk), mount: (body, close) => { const table = el("div", { className: "m02-combo-edit__table" }); const paint = (): void => { const head = el("div", { className: "m02-combo-edit__row m02-combo-edit__row--head", children: [TEXT.no, TEXT.key, TEXT.name, TEXT.unit, TEXT.memo, ""].map((t) => el("span", { text: t }), ), }); const rows = lines.map((line, i) => { const memo = el("input", { className: "ui-input", attrs: { type: "text", value: line.메모 ?? "", title: line.메모 ?? "" }, }); memo.addEventListener("input", () => { line.메모 = memo.value || null; memo.title = memo.value; }); const move = (to: number): void => { if (to < 0 || to >= lines.length) return; [lines[i], lines[to]] = [lines[to], lines[i]]; paint(); }; const up = createButton({ label: "▲", variant: "ghost", size: "sm" }); up.title = TEXT.up; up.disabled = i === 0; up.addEventListener("click", () => move(i - 1)); const down = createButton({ label: "▼", variant: "ghost", size: "sm" }); down.title = TEXT.down; down.disabled = i === lines.length - 1; down.addEventListener("click", () => move(i + 1)); const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" }); del.addEventListener("click", () => { lines.splice(i, 1); paint(); }); return el("div", { className: "m02-combo-edit__row", attrs: { "data-combo-line": line.로직 }, children: [ el("span", { text: String(i + 1) }), el("span", { text: line.로직 }), el("span", { text: line.이름 }), el("span", { text: line.결과단위 }), memo, el("span", { className: "m02-combo-edit__acts", children: [up, down, del] }), ], }); }); table.replaceChildren( head, ...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]), ); }; paint(); const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" }); add.addEventListener("click", () => openLogicPicker((logic) => { lines.push({ 로직: logic.키, 메모: null, 이름: logic.이름, 결과단위: logic.결과단위 }); paint(); }), ); const apply = createButton({ label: TEXT.apply, variant: "filled" }); apply.addEventListener("click", async () => { if (!changed()) { return showToast(TEXT.unchanged, "info"); } if (!lines.length) return showToast(TEXT.noLines, "error"); const message = others ? fill(TEXT.askBody, others + 1) : TEXT.askAlone; const name = await askBranch(message, branchName(comboName, opts.label)); if (name === null) return; apply.disabled = true; try { const result = await branchCombo(opts.column, { 본뜬키: key, 담은로직: lines.map((l) => ({ 로직: l.로직, 메모: l.메모 })), 이름: name, }); branchToast(result); close(); await opts.onApplied(); } catch (error) { apply.disabled = false; showToast(fill(TEXT.failed, reasonOf(error)), "error"); } }); body.append( table, el("div", { className: "m02-combo-edit__foot", children: [add, apply] }), ); }, }); } }