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:
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user