/** * 회사 공유 도면 자산(로고·서명) 고르기 (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; /** * 비었을 때 대신 보여 줄 기본값 — 프로젝트 로고를 안 고르면 도면에는 회사 로고가 * 실린다(`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 } { 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, ): Promise { 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; // 무슨 뜻인지 읽히게 고침 (2026-09-06 사용자 지시) — 체크를 풀면 회사 공용이 된다. 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); }