Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Common.ts
eomsangdonandClaude Opus 5 d50956381d feat(B01): 대시보드 표·기본정보 정비 (2026-09-06 사용자 지시)
- 프로젝트 표의 진행도(%) 열 삭제 — 워크플로 배지가 같은 것을 보여 줌. 상태 문자열로
  따로 세던 서버 계산도 제거(배지는 `project_workflow_stages` 표가 근거라 둘이 어긋났음)
- 시스템 로그 표 = 이메일 · 동작 · 대상 · 일시. 「관리」 문구를 돌려 쓰던 것을 가르고,
  대상(resource_type·id)을 새로 보이며, 일시는 날짜/시각 두 줄(아랫줄 작은 글씨라 행 높이 불변)
- 표 안 관리 버튼을 한 줄로 — 작은 버튼 + 줄바꿈 금지, 폭이 모자라면 표가 가로 스크롤
  (프로젝트 행 높이 105px → 61px, 사용자 행 55px)
- 기본정보는 로그인 본인 화면 — 이메일 라벨을 「팀원 이메일」에서 「이메일」로 바꾸고
  본인 서명 칸을 사용자 수정 모달과 같은 부품으로 추가
- 제목·여백을 공용 템플릿(`createGeneralLayout`)으로 통일, 역할 배지는 제목 줄 오른쪽

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

240 lines
8.1 KiB
TypeScript

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<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-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<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 };
}
/**
* 사용자 정보 입력칸 한 벌 — 기본정보 폼과 사용자 관리 수정 모달이 같은 것을 쓴다
* (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,
}),
};
}