- 모달 닫기 규칙 일원화 — 바깥 클릭·Esc·[취소] 모두 같은 경로(attachModalDismiss). 고친 게 있으면 공용 showConfirmDialog 로 한 번 묻고, 없으면 바로 닫음. 껍데기 세 곳(Modals·AssetPicker·TempModal) 모두 적용. - 확인창 z-index 토큰 --z-confirm(1050) 신설 — 모달(1000) 뒤에 깔리던 문제 해소. - 프로젝트 로고 칸에 회사 기본 연결 표시 — 프로젝트 값이 비면 회사 로고를 「회사 기본 로고 · <자산명>」으로 보이되 저장값은 계속 null(연결 유지). [기본으로] 로 전용 로고 해제. 남의 회사 프로젝트에는 기본을 내밀지 않음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
/**
|
|
* 회사 공유 도면 자산(로고·서명) 고르기 (2026-09-02 사용자 확정).
|
|
*
|
|
* 프로젝트 수정 모달의 로고·서명 칸을 누르면 등록된 목록을 모달로 보여 주고, 없으면
|
|
* 그 자리에서 새로 올린다. 자산은 회사 안에서 공유하며 사용자 계정에 물릴 수도(개인 서명)
|
|
* 아닐 수도(회사 공용 직인) 있다.
|
|
*/
|
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
|
import {
|
|
companyAssetFileUrl,
|
|
createCompanyAsset,
|
|
deleteCompanyAsset,
|
|
fetchCompanyAssets,
|
|
type CompanyAsset,
|
|
type DashboardUser,
|
|
} from "./B01_Dashboard_Api_Fetch";
|
|
import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
|
|
|
|
export interface AssetFieldHandle {
|
|
root: HTMLDivElement;
|
|
value: () => number | null;
|
|
}
|
|
|
|
export interface AssetFieldOptions {
|
|
/** 이 자산의 주인을 사람으로 못박는다 — 사용자 서명 칸(2026-09-02 사용자 확정). */
|
|
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;
|
|
|
|
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
|
|
export function createAssetField(
|
|
label: string,
|
|
kind: CompanyAsset["kind"],
|
|
assets: CompanyAsset[],
|
|
initialId: number | null | undefined,
|
|
companyId: number | null | undefined,
|
|
user: DashboardUser,
|
|
options: AssetFieldOptions = {},
|
|
): AssetFieldHandle {
|
|
const owner = options.owner ?? null;
|
|
// 주인이 못박힌 칸은 그 사람 것(또는 아직 주인 없는 것)만 보인다.
|
|
let list = assets.filter(
|
|
(asset) =>
|
|
asset.kind === kind && (!owner || asset.user_id === owner.id || asset.user_id == null),
|
|
);
|
|
let selected = list.find((asset) => asset.id === initialId) ?? null;
|
|
|
|
const root = document.createElement("div");
|
|
root.className = "ui-field";
|
|
const caption = document.createElement("label");
|
|
caption.className = "ui-field__label";
|
|
caption.textContent = label;
|
|
const row = document.createElement("div");
|
|
row.className = "b01-dashboard__asset-row";
|
|
const preview = document.createElement("img");
|
|
preview.className = "b01-dashboard__asset-preview";
|
|
preview.alt = "";
|
|
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 => {
|
|
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();
|
|
|
|
const pick = createButton({
|
|
label: "선택…",
|
|
variant: "ghost",
|
|
onClick: () =>
|
|
openAssetPickerModal(
|
|
kind,
|
|
list,
|
|
selected?.id ?? null,
|
|
companyId,
|
|
user,
|
|
(next, fresh) => {
|
|
list = fresh;
|
|
selected = next;
|
|
render();
|
|
void options.onChange?.(next?.id ?? null);
|
|
},
|
|
owner,
|
|
),
|
|
});
|
|
row.append(preview, name, pick);
|
|
if (fallback) row.append(reset);
|
|
root.append(caption, row);
|
|
return { root, value: () => selected?.id ?? null };
|
|
}
|
|
|
|
/**
|
|
* 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다.
|
|
* 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다.
|
|
*/
|
|
function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise<File | null> } {
|
|
const root = document.createElement("div");
|
|
root.className = "ui-field";
|
|
const caption = document.createElement("label");
|
|
caption.className = "ui-field__label";
|
|
caption.textContent = "또는 마우스로 그리기";
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 480;
|
|
canvas.height = 160;
|
|
canvas.className = "b01-dashboard__sign-pad";
|
|
const context = canvas.getContext("2d");
|
|
let drawn = false;
|
|
let drawing = false;
|
|
|
|
if (context) {
|
|
context.lineWidth = 2.5;
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
context.strokeStyle = "#111111";
|
|
}
|
|
// 캔버스 좌표는 CSS 크기가 아니라 픽셀 크기 기준이라 비율로 환산한다.
|
|
const at = (event: PointerEvent): [number, number] => {
|
|
const box = canvas.getBoundingClientRect();
|
|
return [
|
|
((event.clientX - box.left) * canvas.width) / box.width,
|
|
((event.clientY - box.top) * canvas.height) / box.height,
|
|
];
|
|
};
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
if (!context) return;
|
|
drawing = true;
|
|
drawn = true;
|
|
canvas.setPointerCapture(event.pointerId);
|
|
const [x, y] = at(event);
|
|
context.beginPath();
|
|
context.moveTo(x, y);
|
|
});
|
|
canvas.addEventListener("pointermove", (event) => {
|
|
if (!drawing || !context) return;
|
|
const [x, y] = at(event);
|
|
context.lineTo(x, y);
|
|
context.stroke();
|
|
});
|
|
const stop = (): void => {
|
|
drawing = false;
|
|
};
|
|
canvas.addEventListener("pointerup", stop);
|
|
canvas.addEventListener("pointercancel", stop);
|
|
|
|
const clear = createButton({
|
|
label: "지우기",
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
context?.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawn = false;
|
|
},
|
|
});
|
|
root.append(caption, canvas, clear);
|
|
|
|
return {
|
|
root,
|
|
toFile: () =>
|
|
new Promise((resolve) => {
|
|
if (!drawn) return resolve(null);
|
|
canvas.toBlob(
|
|
(blob) => resolve(blob ? new File([blob], "signature.png", { type: "image/png" }) : null),
|
|
"image/png",
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 자산 고르기 모달을 버튼 하나로 연다 — 표 안(회사 목록)처럼 칸을 둘 자리가 없을 때.
|
|
* 자산 목록은 열 때 받는다(회사가 여럿이면 미리 다 받을 이유가 없다).
|
|
*/
|
|
export async function openAssetPicker(
|
|
kind: CompanyAsset["kind"],
|
|
companyId: number,
|
|
user: DashboardUser,
|
|
currentId: number | null,
|
|
onPick: (assetId: number | null) => void | Promise<void>,
|
|
): Promise<void> {
|
|
const assets = await fetchCompanyAssets(companyId);
|
|
openAssetPickerModal(
|
|
kind,
|
|
assets.filter((asset) => asset.kind === kind),
|
|
currentId,
|
|
companyId,
|
|
user,
|
|
(next) => void onPick(next?.id ?? null),
|
|
);
|
|
}
|
|
|
|
function openAssetPickerModal(
|
|
kind: CompanyAsset["kind"],
|
|
assets: CompanyAsset[],
|
|
selectedId: number | null,
|
|
companyId: number | null | undefined,
|
|
user: DashboardUser,
|
|
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
|
|
owner: { id: number; name: string } | null = null,
|
|
): void {
|
|
let list = assets;
|
|
const modal = document.createElement("div");
|
|
modal.className = "b01-dashboard__modal";
|
|
const panel = document.createElement("div");
|
|
panel.className = "b01-dashboard__modal-panel";
|
|
const heading = document.createElement("h3");
|
|
heading.className = "b01-dashboard__modal-title";
|
|
heading.textContent = `${KIND_LABEL[kind]} 선택`;
|
|
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);
|
|
close();
|
|
};
|
|
|
|
const card = (asset: CompanyAsset | null): HTMLElement => {
|
|
const item = document.createElement("div");
|
|
item.className = "b01-dashboard__asset";
|
|
if ((asset?.id ?? null) === selectedId) item.classList.add("is-selected");
|
|
item.tabIndex = 0;
|
|
item.addEventListener("click", () => choose(asset));
|
|
item.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") choose(asset);
|
|
});
|
|
if (asset) {
|
|
const image = document.createElement("img");
|
|
image.src = companyAssetFileUrl(asset.id);
|
|
image.alt = asset.label;
|
|
const owner = asset.user_name ? asset.user_name : "회사 공용";
|
|
const text = document.createElement("span");
|
|
text.textContent = `${asset.label} · ${owner}`;
|
|
// 삭제는 두 번 눌러 확정 — 공용 confirm 모달은 이 모달 아래(z-index)에 깔려 못 쓴다.
|
|
const remove = createButton({ label: "삭제", variant: "ghost" });
|
|
remove.addEventListener("click", async (event) => {
|
|
event.stopPropagation();
|
|
if (remove.textContent !== "정말 삭제?") {
|
|
remove.textContent = "정말 삭제?";
|
|
return;
|
|
}
|
|
try {
|
|
await deleteCompanyAsset(asset.id);
|
|
list = list.filter((entry) => entry.id !== asset.id);
|
|
if (asset.id === selectedId) onPick(null, list);
|
|
item.remove();
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "삭제 실패", "error");
|
|
}
|
|
});
|
|
item.append(image, text, remove);
|
|
} else {
|
|
const text = document.createElement("span");
|
|
text.textContent = "(없음)";
|
|
item.append(text);
|
|
}
|
|
return item;
|
|
};
|
|
|
|
grid.append(card(null), ...list.map(card));
|
|
|
|
// 신규 추가 — 이름 + 그림 파일 + (내 계정에 물릴지)
|
|
const addTitle = document.createElement("h4");
|
|
addTitle.className = "b01-dashboard__modal-subtitle";
|
|
addTitle.textContent = "신규 추가";
|
|
const label = createInputField({ label: "이름", placeholder: `예: ${KIND_LABEL[kind]} 2026` });
|
|
const file = createInputField({ label: "그림 파일 (png·jpg·webp·svg, 2MB 이하)" });
|
|
file.input.type = "file";
|
|
file.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
|
const mine = document.createElement("label");
|
|
mine.className = "b01-dashboard__check";
|
|
const mineBox = document.createElement("input");
|
|
mineBox.type = "checkbox";
|
|
mineBox.checked = owner !== null || kind === "SIGNATURE";
|
|
// 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다.
|
|
mineBox.disabled = owner !== null;
|
|
mine.append(
|
|
mineBox,
|
|
document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`),
|
|
);
|
|
const pad = kind === "SIGNATURE" ? createSignaturePad() : null;
|
|
const add = createButton({
|
|
label: "올리고 선택",
|
|
onClick: async () => {
|
|
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
|
const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null;
|
|
if (!chosen) {
|
|
return file.setError(
|
|
pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.",
|
|
);
|
|
}
|
|
const form = new FormData();
|
|
form.append("kind", kind);
|
|
form.append("label", label.input.value.trim());
|
|
form.append("file", chosen);
|
|
if (mineBox.checked) form.append("user_id", String(owner ? owner.id : user.id));
|
|
if (companyId) form.append("company_id", String(companyId));
|
|
try {
|
|
const id = await createCompanyAsset(form);
|
|
const fresh = await fetchCompanyAssets(companyId);
|
|
list = fresh.filter((asset) => asset.kind === kind);
|
|
choose(list.find((asset) => asset.id === id) ?? null);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "업로드 실패", "error");
|
|
}
|
|
},
|
|
});
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "b01-dashboard__actions";
|
|
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);
|
|
}
|