Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Combo.ts
T
eomsangdonandClaude Opus 5.5 841af0a033 fix(M02): 16장 점검 — 면적산출 · 재료산출 · 조합 고치기 · 알약 · 자재 옆 판 흠 고침
- 조합 고치기 [로직 더하기] 창이 M02 에서 화면 밖(페이지 아래)으로 뜨던 것 — 고르기 창 겉틀 CSS 를 같이 받음
- 조합 고치기 창을 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음(공용 창 틀에 beforeClose 더함)
- 새 조합 이름: 빈 이름 · 60자 넘음은 까닭 글로 막음(서버 422 「HTTP 422」 대신) · Enter 로 만듦 · 자동 이름 겹침 없앰(구조물 이름이 조합 이름을 품으면 하나만)
- 담은 로직 · 담은 자재를 다 빼고 [적용] — 서버 400 대신 미리 까닭 알림
- 옆 판에서 이은 자재 [빼기] — 확인 글대로 연결을 바로 풂(전엔 적용 전까지 알약이 연결된 채 남음)
- 이미 담은 자재를 또 고르면 알림 · 메모 칸 전체 글을 title 로
- 알약 우클릭 메뉴가 누른 자리에서 부품 원점만큼 밀려 뜨던 것 바로잡음
- 저장 전 새 열(분류 더하기)의 [수정] — 집계표 고침을 버리고 빈 화면으로 가던 것 막고 「집계표를 먼저 저장」 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
2026-09-28 22:09:24 +09:00

301 lines
12 KiB
TypeScript

/* =============================================================================
* 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 "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // [로직 더하기] 창(M01 고르기) 겉틀 — M02 에서 열 때도
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<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.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 });
});
}
/** 새 조합 이름 자동 — 원래 이름 · 구조물 이름(한쪽이 다른 쪽을 이미 품으면 긴 쪽 하나만) */
export const branchName = (original: string, label: string): 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<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 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] }),
);
},
});
}
}