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
This commit is contained in:
2026-09-28 22:09:24 +09:00
co-authored by Claude Opus 5.5
parent 527e68db85
commit 841af0a033
5 changed files with 59 additions and 20 deletions
+2 -1
View File
@@ -209,7 +209,8 @@ export function mountLinks(
e.preventDefault();
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
menu.open(e.clientX, e.clientY, [
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
menu.open(e.clientX - o.left, e.clientY - o.top, [
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
...(inList
? [
+41 -8
View File
@@ -6,12 +6,19 @@
* 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀.
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
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 = {
@@ -35,6 +42,11 @@ const TEXT = {
close: "닫기",
empty: "담은 로직이 없음",
unchanged: "고친 것이 없음",
discard: "고친 것을 버리고 닫음",
discardOk: "버리고 닫음",
nameEmpty: "이름을 넣음",
noLines: "담은 로직이 없어 적용 안 됨 — 로직을 하나 이상 담음",
nameLong: "이름은 {n}자까지",
loadFailed: "조합을 못 읽음 — {value}",
askTitle: "새 조합으로 갈라 냄",
askName: "새 조합 이름",
@@ -49,6 +61,9 @@ const TEXT = {
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));
@@ -66,17 +81,23 @@ export function askBranch(message: string, defaultName: string): Promise<string
dialogClass: "m02-modal m02-branch",
mount: (body, close) => {
const name = createInputField({ label: TEXT.askName, type: "text" });
name.input.value = defaultName;
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) return name.input.focus();
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,
@@ -95,9 +116,13 @@ export function askBranch(message: string, defaultName: string): Promise<string
});
}
/** 새 조합 이름 자동 — 원래 이름 · 구조물 이름(원래 이름이 이미 구조물 이름으로 끝나면 덧붙이지 않음) */
/** 새 조합 이름 자동 — 원래 이름 · 구조물 이름(한쪽이 다른 쪽을 이미 품으면 긴 쪽 하나만) */
export const branchName = (original: string, label: string): string =>
!original ? label : !label || original.endsWith(label) ? original : `${original} · ${label}`;
!original || label.includes(original)
? label || original
: !label || original.includes(label)
? original
: `${original} · ${label}`;
/** 결과 알림 — 새로 만듦 / 같은 조합 씀 */
export const branchToast = (r: BranchedCombo): void =>
@@ -163,6 +188,8 @@ export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement
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.메모,
@@ -173,6 +200,8 @@ export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement
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 => {
@@ -185,9 +214,12 @@ export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement
const rows = lines.map((line, i) => {
const memo = el("input", {
className: "ui-input",
attrs: { type: "text", value: line.메모 ?? "" },
attrs: { type: "text", value: line.메모 ?? "", title: line.메모 ?? "" },
});
memo.addEventListener("input", () => {
line.메모 = memo.value || null;
memo.title = memo.value;
});
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]];
@@ -236,9 +268,10 @@ export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement
);
const apply = createButton({ label: TEXT.apply, variant: "filled" });
apply.addEventListener("click", async () => {
if (JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) === original) {
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;
@@ -40,6 +40,8 @@ const TEXT = {
apply: "적용",
undo: "되돌리기",
empty: "담은 자재가 없음",
noItems: "담은 자재가 없어 적용 안 됨 — 자재를 하나 이상 담음",
already: "이미 담은 자재 — {value}",
unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂",
unlinkOk: "빼고 연결 풂",
loadFailed: "자재 조합을 못 읽음 — {value}",
@@ -233,7 +235,7 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
)
return;
items.splice(i, 1);
removed.push(m.코드);
if (cells.length) opts.unlink([m.코드]); // 확인 글대로 바로 풂(알약 막대 「목록에서 빼기」와 같음)
paint();
opts.onChange(items);
});
@@ -257,16 +259,14 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
paintToggle();
};
/** 이번에 뺀 자재 코드 — 적용하면 이은 칸 연결도 풂 */
let removed: string[] = [];
const paintFoot = (): void => {
if (!opts.isAdmin) return foot.replaceChildren();
const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" });
add.addEventListener("click", () =>
pickMaterial((m) => {
if (items.some((x) => x.코드 === m.코드)) return;
if (items.some((x) => x.코드 === m.코드))
return showToast(fill(TEXT.already, { value: m.코드 }), "info");
items.push(m);
removed = removed.filter((c) => c !== m.코드);
paint();
opts.onChange(items);
}),
@@ -275,6 +275,7 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
if (sig(items) !== sig(saved)) {
const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" });
apply.addEventListener("click", async () => {
if (!items.length) return showToast(TEXT.noItems, "error");
const message = !key
? TEXT.askFirst
: others
@@ -290,8 +291,6 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
이름: name,
});
branchToast(result);
if (removed.length) opts.unlink(removed);
removed = [];
key = result.키;
others = 0;
combo = await fetchMaterialCombo(key).catch(() => null);
@@ -306,7 +305,6 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
const undo = createButton({ label: TEXT.undo, variant: "ghost", size: "sm" });
undo.addEventListener("click", () => {
items = [...saved];
removed = [];
paint();
opts.onChange(items);
});
@@ -501,6 +501,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
async function openEdit(kind: EditKind): Promise<void> {
const at = column;
if (!at) return;
// 저장 전 새 열(분류 더하기) — 문서 · 번호가 아직 없음 · 떠나면 집계표 고침까지 버리게 됨
if (!numbers[at]?.[PARTS[kind].field]) return void showToast(TEXT.saveFirst, "info");
if (!(await opts.confirmLeave())) return;
opts.onEdit(at, columnLabel, kind);
}
+8 -3
View File
@@ -13,11 +13,16 @@ export interface ModalOptions {
mount: (body: HTMLElement, close: () => void) => void;
dialogClass?: string;
backdropClass?: string;
/** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */
beforeClose?: () => boolean | Promise<boolean>;
}
export function openModal(opts: ModalOptions): { close: () => void } {
const body = el("div", { className: "ui-modal__body" });
const close = (): void => backdrop.remove();
const ask = async (): Promise<void> => {
if (!opts.beforeClose || (await opts.beforeClose())) close();
};
const dialog = el("div", {
className: `ui-modal ${opts.dialogClass ?? ""}`.trim(),
attrs: { role: "dialog", "aria-label": opts.title },
@@ -26,7 +31,7 @@ export function openModal(opts: ModalOptions): { close: () => void } {
className: "ui-modal__top",
children: [
el("h3", { text: opts.title }),
createButton({ label: opts.closeLabel, variant: "ghost", onClick: close }),
createButton({ label: opts.closeLabel, variant: "ghost", onClick: () => void ask() }),
],
}),
body,
@@ -36,8 +41,8 @@ export function openModal(opts: ModalOptions): { close: () => void } {
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
children: [dialog],
});
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
backdrop.addEventListener("click", (ev) => ev.target === backdrop && void ask());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
opts.mount(body, close);
document.body.append(backdrop);
dialog.tabIndex = -1;