diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 0be3ca67..348ee8e2 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -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 ? [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts index 1fe94d9f..80dddf90 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -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 { 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 - !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 [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 !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 { 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 { - 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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts index 79f52ec1..48aaa826 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -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); }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index a52ae745..3220cab2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -501,6 +501,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane async function openEdit(kind: EditKind): Promise { 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); } diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index ee92cc82..61329e4f 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -13,11 +13,16 @@ export interface ModalOptions { mount: (body: HTMLElement, close: () => void) => void; dialogClass?: string; backdropClass?: string; + /** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */ + beforeClose?: () => boolean | Promise; } export function openModal(opts: ModalOptions): { close: () => void } { const body = el("div", { className: "ui-modal__body" }); const close = (): void => backdrop.remove(); + const ask = async (): Promise => { + 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;