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>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
type CompanyAsset,
|
||||
type DashboardUser,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export interface AssetFieldHandle {
|
||||
root: HTMLDivElement;
|
||||
@@ -25,6 +26,13 @@ export interface AssetFieldOptions {
|
||||
owner?: { id: number; name: string } | null;
|
||||
/** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */
|
||||
onChange?: (assetId: number | null) => void | Promise<void>;
|
||||
/**
|
||||
* 비었을 때 대신 보여 줄 기본값 — 프로젝트 로고를 안 고르면 도면에는 회사 로고가
|
||||
* 실린다(`COALESCE(p.logo_asset_id, c.logo_asset_id)`). 그 사실을 화면에도 보인다
|
||||
* (2026-09-04 사용자 지시). **저장값은 계속 null** — 회사 로고를 바꾸면 따라가야 하므로
|
||||
* 값을 복사해 굳히지 않는다.
|
||||
*/
|
||||
fallback?: { asset: CompanyAsset | null; prefix: string; missingNote: string };
|
||||
}
|
||||
|
||||
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
|
||||
@@ -60,10 +68,29 @@ export function createAssetField(
|
||||
const name = document.createElement("span");
|
||||
name.className = "b01-dashboard__asset-name";
|
||||
|
||||
const fallback = options.fallback ?? null;
|
||||
const reset = createButton({
|
||||
label: "기본으로",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
selected = null;
|
||||
render();
|
||||
void options.onChange?.(null);
|
||||
},
|
||||
});
|
||||
|
||||
const render = (): void => {
|
||||
preview.hidden = !selected;
|
||||
if (selected) preview.src = companyAssetFileUrl(selected.id);
|
||||
name.textContent = selected ? selected.label : "(없음)";
|
||||
const shown = selected ?? fallback?.asset ?? null;
|
||||
preview.hidden = !shown;
|
||||
if (shown) preview.src = companyAssetFileUrl(shown.id);
|
||||
if (selected) name.textContent = selected.label;
|
||||
else if (fallback) {
|
||||
name.textContent = fallback.asset
|
||||
? `${fallback.prefix} · ${fallback.asset.label}`
|
||||
: fallback.missingNote;
|
||||
} else name.textContent = "(없음)";
|
||||
// 프로젝트 전용 값을 골랐을 때만 기본 연결로 되돌릴 거리가 생긴다.
|
||||
reset.hidden = !fallback || selected === null;
|
||||
};
|
||||
render();
|
||||
|
||||
@@ -87,6 +114,7 @@ export function createAssetField(
|
||||
),
|
||||
});
|
||||
row.append(preview, name, pick);
|
||||
if (fallback) row.append(reset);
|
||||
root.append(caption, row);
|
||||
return { root, value: () => selected?.id ?? null };
|
||||
}
|
||||
@@ -209,6 +237,7 @@ function openAssetPickerModal(
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__asset-grid";
|
||||
|
||||
let dismiss: ModalDismissHandle | null = null;
|
||||
const close = (): void => modal.remove();
|
||||
const choose = (asset: CompanyAsset | null): void => {
|
||||
onPick(asset, list);
|
||||
@@ -308,10 +337,14 @@ function openAssetPickerModal(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
|
||||
actions.append(
|
||||
createButton({ label: "닫기", variant: "ghost", onClick: () => void dismiss?.tryClose() }),
|
||||
);
|
||||
panel.append(heading, grid, addTitle, label.root, file.root);
|
||||
if (pad) panel.append(pad.root);
|
||||
panel.append(mine, add, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
// 「신규 추가」에 이름·파일을 넣어 두고 바깥을 누르면 한 번 묻는다 (2026-09-04 사용자 지시).
|
||||
dismiss = attachModalDismiss(modal, panel);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
/**
|
||||
@@ -33,3 +38,107 @@ export async function runRequest(action: () => Promise<unknown>): Promise<void>
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
addCompanyMember,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyAssets,
|
||||
fetchUserCompany,
|
||||
updateCompanyAsset,
|
||||
createCompanyAsset,
|
||||
setCompanyLogo,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */
|
||||
const NEW_MEMBER = "__new__";
|
||||
@@ -35,7 +37,12 @@ function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<void>): void {
|
||||
function openModal(
|
||||
title: string,
|
||||
body: HTMLElement[],
|
||||
onConfirm: () => Promise<void>,
|
||||
options: { extra?: () => string } = {},
|
||||
): void {
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b01-dashboard__modal";
|
||||
const panel = document.createElement("div");
|
||||
@@ -45,11 +52,13 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
|
||||
heading.textContent = title;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
// 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다.
|
||||
let dismiss: ModalDismissHandle | null = null;
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => modal.remove(),
|
||||
onClick: () => void dismiss?.tryClose(),
|
||||
}),
|
||||
createButton({
|
||||
label: L("Common_Btn_Confirm"),
|
||||
@@ -73,6 +82,7 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
|
||||
panel.append(heading, ...body, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
dismiss = attachModalDismiss(modal, panel, { extra: options.extra });
|
||||
}
|
||||
|
||||
export async function openEditProjectModal(
|
||||
@@ -81,10 +91,18 @@ export async function openEditProjectModal(
|
||||
): Promise<void> {
|
||||
const isUserOnly = user.role === "USER";
|
||||
// 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정).
|
||||
const [members, assets] = await Promise.all([
|
||||
// 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시).
|
||||
const [members, assets, company] = await Promise.all([
|
||||
fetchCompanyMembers(project.company_id),
|
||||
fetchCompanyAssets(project.company_id),
|
||||
fetchUserCompany().catch(() => null),
|
||||
]);
|
||||
// 남의 회사 프로젝트(시스템관리자)에서는 내 회사 로고를 기본으로 내밀지 않는다.
|
||||
const sameCompany = company != null && company.id === project.company_id;
|
||||
const companyLogo =
|
||||
(sameCompany &&
|
||||
assets.find((asset) => asset.kind === "LOGO" && asset.id === company.logo_asset_id)) ||
|
||||
null;
|
||||
|
||||
const name = createInputField({
|
||||
label: L("B01_Dashboard_Table_Project"),
|
||||
@@ -174,6 +192,16 @@ export async function openEditProjectModal(
|
||||
project.logo_asset_id,
|
||||
project.company_id,
|
||||
user,
|
||||
{
|
||||
// 프로젝트가 안 고르면 도면에는 회사 로고가 실린다 — 저장값은 계속 비워 둬 연결을 유지한다.
|
||||
fallback: sameCompany
|
||||
? {
|
||||
asset: companyLogo,
|
||||
prefix: "회사 기본 로고",
|
||||
missingNote: "(없음) — 회사 로고 미지정 (회사 정보 화면의 「로고 지정…」)",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (isUserOnly) {
|
||||
name.input.disabled = true;
|
||||
@@ -212,28 +240,36 @@ export async function openEditProjectModal(
|
||||
);
|
||||
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
|
||||
|
||||
openModal(L("B01_Dashboard_EditProject"), [grid], async () => {
|
||||
await updateProject(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
region: region.input.value.trim() || null,
|
||||
road_type: roadType.input.value.trim() || null,
|
||||
project_year: year.input.value ? Number(year.input.value) : null,
|
||||
estimated_length_m: length.input.value ? Number(length.input.value) : null,
|
||||
memo: memo.input.value.trim() || null,
|
||||
status: project.status,
|
||||
client_org: clientOrg.input.value.trim() || null,
|
||||
project_number: projectNumber.input.value.trim() || null,
|
||||
work_amount: workAmount.input.value.trim() || null,
|
||||
design_date: designDate.input.value || null,
|
||||
pm_user_id: userId(pm.select),
|
||||
field_lead_user_id: userId(fieldLead.select),
|
||||
designer_user_id: userId(designer.select),
|
||||
logo_asset_id: logo.value(),
|
||||
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
|
||||
signature_asset_id: null,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
});
|
||||
// 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다.
|
||||
const editProjectExtra = (): string => String(logo.value() ?? "");
|
||||
|
||||
openModal(
|
||||
L("B01_Dashboard_EditProject"),
|
||||
[grid],
|
||||
async () => {
|
||||
await updateProject(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
region: region.input.value.trim() || null,
|
||||
road_type: roadType.input.value.trim() || null,
|
||||
project_year: year.input.value ? Number(year.input.value) : null,
|
||||
estimated_length_m: length.input.value ? Number(length.input.value) : null,
|
||||
memo: memo.input.value.trim() || null,
|
||||
status: project.status,
|
||||
client_org: clientOrg.input.value.trim() || null,
|
||||
project_number: projectNumber.input.value.trim() || null,
|
||||
work_amount: workAmount.input.value.trim() || null,
|
||||
design_date: designDate.input.value || null,
|
||||
pm_user_id: userId(pm.select),
|
||||
field_lead_user_id: userId(fieldLead.select),
|
||||
designer_user_id: userId(designer.select),
|
||||
logo_asset_id: logo.value(),
|
||||
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
|
||||
signature_asset_id: null,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
},
|
||||
{ extra: editProjectExtra },
|
||||
);
|
||||
}
|
||||
|
||||
export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
|
||||
export function tempFileType(fileName: string): string {
|
||||
@@ -113,11 +113,13 @@ export function openTempFileModal(options: TempModalOptions): void {
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
// 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다 (2026-09-04 사용자 지시).
|
||||
let dismiss: ModalDismissHandle | null = null;
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => modal.remove(),
|
||||
onClick: () => void dismiss?.tryClose(),
|
||||
}),
|
||||
createButton({
|
||||
label: L("Common_Btn_Confirm"),
|
||||
@@ -143,4 +145,8 @@ export function openTempFileModal(options: TempModalOptions): void {
|
||||
panel.append(pickRow, listHost, picker, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
// 고른 파일은 입력칸이 아니라 목록에 쌓이므로 따로 견준다.
|
||||
dismiss = attachModalDismiss(modal, panel, {
|
||||
extra: () => chosen.map((file) => `${file.name}:${file.size}`).join(","),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ const BASE_CSS = `
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(38, 17, 74, 0.35);
|
||||
z-index: var(--z-overlay);
|
||||
z-index: var(--z-confirm);
|
||||
}
|
||||
.ui-confirm__panel {
|
||||
min-width: 300px;
|
||||
|
||||
@@ -197,6 +197,8 @@
|
||||
--z-dropdown: 200;
|
||||
--z-overlay: 900;
|
||||
--z-modal: 1000;
|
||||
/* 확인창은 모달 위에 떠야 한다 — 모달 닫기 확인이 모달 뒤에 깔리면 못 누른다. */
|
||||
--z-confirm: 1050;
|
||||
--z-toast: 1100;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user