import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createInputField, 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): Promise { 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-06 사용자 지시). */ export function formatTime(value?: string | null): string { if (!value) return ""; const time = value.includes("T") ? value.split("T")[1] : value.slice(11); return time ? time.slice(0, 5) : ""; } /** * 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**. * 날짜/시각처럼 한 칸에 두 값을 넣을 때 쓴다. */ export function stackedCell(top: string, bottom: string): HTMLElement { const cell = document.createElement("div"); cell.className = "b01-dashboard__stacked"; const first = document.createElement("span"); first.textContent = top; cell.append(first); if (bottom) { const second = document.createElement("span"); second.className = "b01-dashboard__stacked-sub"; second.textContent = bottom; cell.append(second); } return cell; } /* ----------------------------------------------------------------------------- * 모달 바깥 클릭으로 닫기 (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; } 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 => { if (!isDirty()) return close(); const ok = await showConfirmDialog("변경한 내용이 저장되지 않습니다. 닫을까요?", "닫기"); if (ok) close(); }; // 바탕(패널 바깥) 위에서 굴린 휠이 뒤 화면을 움직이지 않게 막는다 // (2026-09-13 사용자 지시 — 모달을 열어 둔 채 대시보드가 함께 굴렀다). modal.addEventListener( "wheel", (event) => { if (event.target === modal) event.preventDefault(); }, { passive: false }, ); // 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다. 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 }; } /** * 사용자 정보 입력칸 한 벌 — 기본정보 폼과 사용자 관리 수정 모달이 같은 것을 쓴다 * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 * 이메일은 계정 식별자라 읽기 전용이다. */ export function buildUserFields( source: { name: string; email?: string; position?: string | null; department?: string | null; phone?: string | null; }, /** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */ options: { self?: boolean } = {}, ): { grid: HTMLElement; validate: () => boolean; values: () => { name: string; position: string | null; department: string | null; phone: string | null; }; } { const name = createInputField({ label: L("B01_Account_Field_Name"), value: source.name, required: true, }); const position = createInputField({ label: L("B01_Dashboard_Table_Position"), value: source.position ?? "", }); const email = createInputField({ label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"), type: "email", value: source.email ?? "", }); email.input.disabled = true; const department = createInputField({ label: L("B01_Dashboard_Table_Department"), value: source.department ?? "", }); const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: source.phone ?? "", }); const grid = document.createElement("div"); grid.className = "b01-dashboard__form-grid"; grid.append(name.root, position.root, email.root, department.root, phone.root); return { grid, validate: () => { name.setError(); if (name.input.value.trim()) return true; name.setError(L("Common_Msg_RequiredField")); return false; }, values: () => ({ name: name.input.value.trim(), position: position.input.value.trim() || null, department: department.input.value.trim() || null, phone: phone.input.value.trim() || null, }), }; }