- 모달 닫기 규칙 일원화 — 바깥 클릭·Esc·[취소] 모두 같은 경로(attachModalDismiss). 고친 게 있으면 공용 showConfirmDialog 로 한 번 묻고, 없으면 바로 닫음. 껍데기 세 곳(Modals·AssetPicker·TempModal) 모두 적용. - 확인창 z-index 토큰 --z-confirm(1050) 신설 — 모달(1000) 뒤에 깔리던 문제 해소. - 프로젝트 로고 칸에 회사 기본 연결 표시 — 프로젝트 값이 비면 회사 로고를 「회사 기본 로고 · <자산명>」으로 보이되 저장값은 계속 null(연결 유지). [기본으로] 로 전용 로고 해제. 남의 회사 프로젝트에는 기본을 내밀지 않음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
5.1 KiB
TypeScript
145 lines
5.1 KiB
TypeScript
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import {
|
|
hideLoadingOverlay,
|
|
showConfirmDialog,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
|
|
|
|
/**
|
|
* 목록형 컨테이너가 한 번에 보여 주는 행 수. 넘치는 만큼은 컨테이너 안에서 스크롤한다 —
|
|
* 자료가 쌓여도 대시보드 세로 길이가 늘어나지 않게(2026-08-08 사용자 지시).
|
|
*/
|
|
export const DASHBOARD_VISIBLE_ROWS = 4;
|
|
|
|
export function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
export function roleLabel(role: DashboardUser["role"]): string {
|
|
if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin");
|
|
if (role === "ADMIN") return L("B01_Dashboard_Role_Admin");
|
|
return L("B01_Dashboard_Role_User");
|
|
}
|
|
|
|
export async function runRequest(action: () => Promise<unknown>): Promise<void> {
|
|
showLoadingOverlay();
|
|
try {
|
|
await action();
|
|
showToast(L("B01_Dashboard_Saved"), "success");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
export function formatDate(value?: string | null): string {
|
|
return value ? value.slice(0, 10) : "-";
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시)
|
|
*
|
|
* 껍데기를 만드는 자리가 세 곳(Modals·AssetPicker·TempModal)이라 규칙을 여기 한 곳에
|
|
* 둔다. 고친 게 있으면 공용 `showConfirmDialog` 로 한 번 묻고, 없으면 바로 닫는다.
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
/** 모달 안 입력값을 한 줄로 떠 둔다 — 열 때와 닫을 때를 견주어 변경을 판정한다. */
|
|
export function snapshotModalFields(panel: HTMLElement): string {
|
|
const parts: string[] = [];
|
|
for (const node of panel.querySelectorAll("input, select, textarea")) {
|
|
if (node instanceof HTMLInputElement && node.type === "checkbox") {
|
|
parts.push(node.checked ? "1" : "0");
|
|
} else if (node instanceof HTMLInputElement && node.type === "file") {
|
|
parts.push(
|
|
Array.from(node.files ?? [])
|
|
.map((file) => `${file.name}:${file.size}`)
|
|
.join(","),
|
|
);
|
|
} else {
|
|
parts.push((node as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).value);
|
|
}
|
|
}
|
|
return parts.join("\u0001");
|
|
}
|
|
|
|
export interface ModalDismissOptions {
|
|
/** 입력칸 밖에서 바뀌는 값(고른 로고·고른 파일 목록)을 덧붙인다. */
|
|
extra?: () => string;
|
|
/** 실제로 닫는 동작 — 기본은 모달 제거. */
|
|
close?: () => void;
|
|
}
|
|
|
|
export interface ModalDismissHandle {
|
|
isDirty: () => boolean;
|
|
/** 변경이 있으면 확인창을 거쳐 닫는다 — [취소] 단추도 이것을 쓴다. */
|
|
tryClose: () => Promise<void>;
|
|
}
|
|
|
|
export function attachModalDismiss(
|
|
modal: HTMLElement,
|
|
panel: HTMLElement,
|
|
options: ModalDismissOptions = {},
|
|
): ModalDismissHandle {
|
|
const extraOf = (): string => options.extra?.() ?? "";
|
|
let baseline = snapshotModalFields(panel);
|
|
const extraBaseline = extraOf();
|
|
// 값을 나중에 채우는 칸(비동기 조회)이 있어, 사용자가 아직 손대기 전이면 기준을 다시 뜬다.
|
|
let touched = false;
|
|
const mark = (): void => {
|
|
touched = true;
|
|
};
|
|
panel.addEventListener("input", mark, true);
|
|
panel.addEventListener("change", mark, true);
|
|
|
|
const isDirty = (): boolean => {
|
|
if (extraOf() !== extraBaseline) return true;
|
|
const now = snapshotModalFields(panel);
|
|
if (!touched) {
|
|
baseline = now;
|
|
return false;
|
|
}
|
|
return now !== baseline;
|
|
};
|
|
|
|
const onKey = (event: KeyboardEvent): void => {
|
|
if (!modal.isConnected) {
|
|
document.removeEventListener("keydown", onKey, true);
|
|
return;
|
|
}
|
|
if (event.key !== "Escape") return;
|
|
// 확인창이 떠 있으면 그쪽이 먼저고, 모달이 여럿이면 맨 위 것만 닫는다.
|
|
if (document.querySelector(".ui-confirm")) return;
|
|
const opened = document.querySelectorAll(".b01-dashboard__modal");
|
|
if (opened[opened.length - 1] !== modal) return;
|
|
event.stopPropagation();
|
|
void tryClose();
|
|
};
|
|
|
|
const close = (): void => {
|
|
document.removeEventListener("keydown", onKey, true);
|
|
if (options.close) options.close();
|
|
else modal.remove();
|
|
};
|
|
|
|
const tryClose = async (): Promise<void> => {
|
|
if (!isDirty()) return close();
|
|
const ok = await showConfirmDialog("변경한 내용이 저장되지 않습니다. 닫을까요?", "닫기");
|
|
if (ok) close();
|
|
};
|
|
|
|
// 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다.
|
|
let downOnOverlay = false;
|
|
modal.addEventListener("mousedown", (event) => {
|
|
downOnOverlay = event.target === modal;
|
|
});
|
|
modal.addEventListener("click", (event) => {
|
|
if (event.target === modal && downOnOverlay) void tryClose();
|
|
});
|
|
document.addEventListener("keydown", onKey, true);
|
|
|
|
return { isDirty, tryClose };
|
|
}
|