Files
Aislo/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts
eomsangdonandClaude Opus 5 aed7465a5f feat(B01,B02): 프로젝트 참여자 다중 지정·가입요청 목록 정리·서명 문구 개선
- project_members 표 신설(016 마이그레이션, 기존 프로젝트는 생성자 자동 채움)
- 프로젝트 수정 모달에 참여자 선택 추가, 참여자로 지정된 일반사용자는 해당 프로젝트 수정 가능 (백엔드 _can_edit_project 동반 수정)
- B02 프로젝트 생성 시 생성자를 참여자로 자동 등록, 참여자도 같은 회사 구성원인지 검증
- 가입 요청 목록을 대기 상태만 표시 (처리 완료 건이 사용자 관리 목록과 중복 노출되던 문제)
- 팀원 등록 시 남아 있던 가입 신청 자동 정리
- 서명·로고 선택 문구를 뜻이 드러나게 수정

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

354 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;
// 무슨 뜻인지 읽히게 고침 (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);
}