손으로 정확히 쳐야만 되던 주소칸을 후보 검색·선택 방식으로 바꿈. VWorld 주소검색을 서버가 중계하고, 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 그림. 못 찾는 주소는 「직접 입력」으로 옛 방식을 씀. - 회사 목록 SQL 이 business_address·business_owner 를 안 가져와 수정 모달이 비어 열리고 저장 시 빈 값으로 덮어쓰던 것 수정 - 지도 기본 배율 15 → 17, +·- 단계 조절 추가 - 라벨·입력·버튼을 한 줄에 눕혀 줄 어긋남 해소 - 모달 안 휠이 뒤 대시보드로 새던 것 차단 - 회사 패널에 주소 한 줄 표시 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM
250 lines
8.4 KiB
TypeScript
250 lines
8.4 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();
|
|
};
|
|
|
|
// 바탕(패널 바깥) 위에서 굴린 휠이 뒤 화면을 움직이지 않게 막는다
|
|
// (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,
|
|
}),
|
|
};
|
|
}
|