Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Common.ts
T
eomsangdonandClaude Opus 5 149920f3b5 feat(B01): 모달 바깥 클릭 닫기 + 회사 로고 기본 연결
- 모달 닫기 규칙 일원화 — 바깥 클릭·Esc·[취소] 모두 같은 경로(attachModalDismiss).
  고친 게 있으면 공용 showConfirmDialog 로 한 번 묻고, 없으면 바로 닫음.
  껍데기 세 곳(Modals·AssetPicker·TempModal) 모두 적용.
- 확인창 z-index 토큰 --z-confirm(1050) 신설 — 모달(1000) 뒤에 깔리던 문제 해소.
- 프로젝트 로고 칸에 회사 기본 연결 표시 — 프로젝트 값이 비면 회사 로고를
  「회사 기본 로고 · <자산명>」으로 보이되 저장값은 계속 null(연결 유지).
  [기본으로] 로 전용 로고 해제. 남의 회사 프로젝트에는 기본을 내밀지 않음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:05:23 +09:00

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 };
}