- 알약 막대: 키가 목록에 없는 옛 연결(로직 키)을 이름 + 단위가 같은 알약으로 이어 새 키로 바꿈 — 따로 붙던 「연결」 알약 제거 - 재료산출 옆 판: 알약을 이음 · 풂 즉시 「이은 칸」 갱신 - 새 조합 이름: 원래 이름이 구조물 이름으로 끝나면 덧붙이지 않음(일위대가 · 자재) - 자재 조합이 없는 열의 첫 생성 모달 글: 「이 열에 새 자재 조합을 만듦」 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Combo.ts
|
|
* 면적산출 편집 화면 — 알약 막대 위 조합 줄 + [조합 고치기](시스템 관리자만).
|
|
* 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어
|
|
* 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양.
|
|
* 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀.
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, el, 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: "고친 것이 없음",
|
|
loadFailed: "조합을 못 읽음 — {value}",
|
|
askTitle: "새 조합으로 갈라 냄",
|
|
askName: "새 조합 이름",
|
|
askOk: "새로 만듦",
|
|
askCancel: "취소",
|
|
askBody:
|
|
"이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로",
|
|
askAlone:
|
|
"이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로",
|
|
made: "새 조합 {value} 을 만들고 이 열이 가리킴",
|
|
reused: "같은 내용의 조합 {value} 을 씀",
|
|
failed: "조합을 못 고침 — {value}",
|
|
};
|
|
|
|
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<string | null> {
|
|
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;
|
|
const ok = createButton({
|
|
label: TEXT.askOk,
|
|
variant: "filled",
|
|
onClick: () => {
|
|
const value = name.input.value.trim();
|
|
if (!value) return name.input.focus();
|
|
finish(value);
|
|
close();
|
|
},
|
|
});
|
|
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 });
|
|
});
|
|
}
|
|
|
|
/** 새 조합 이름 자동 — 원래 이름 · 구조물 이름(원래 이름이 이미 구조물 이름으로 끝나면 덧붙이지 않음) */
|
|
export const branchName = (original: string, label: string): string =>
|
|
!original ? label : !label || original.endsWith(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<boolean>;
|
|
/** 갈라 낸 뒤 — 알약 다시 받기 */
|
|
onApplied: () => Promise<void> | void;
|
|
}
|
|
|
|
interface Line {
|
|
로직: string;
|
|
메모: string | null;
|
|
이름: string;
|
|
결과단위: string;
|
|
}
|
|
|
|
/** 조합 줄 — 이 열에 일위대가가 없으면 null */
|
|
export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement | null> {
|
|
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 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",
|
|
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.메모 ?? "" },
|
|
});
|
|
memo.addEventListener("input", () => (line.메모 = memo.value || null));
|
|
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 (JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) === original) {
|
|
return showToast(TEXT.unchanged, "info");
|
|
}
|
|
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] }),
|
|
);
|
|
},
|
|
});
|
|
}
|
|
}
|