Files
Aislo/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts
T
eomsangdonandClaude Opus 5 3a36af9a2c feat(B01): 서명은 사람 계정에, 회사 로고는 회사 등록 단계로 재배치
사용자 확정(2026-09-02) — 이름이 들어가는 자리는 서명도 받고, 담당자 선택에 신규 등록
항목을 두며, 회사 로고는 회사 등록 단계에서 받아 이후 변경.

- 014_company_logo.sql: companies.logo_asset_id 추가 (서명은 company_assets.user_id 로
  이미 표현되어 컬럼 없음). 공유 DB 적용 완료
- 사용자 수정 모달에 「서명 (도면 표제란)」 칸 — createAssetField 에 owner(주인 못박기)·
  onChange(즉시 반영) 추가로 재사용, 주인 고정 시 물리기 체크 잠금
- 담당자 select 3개에 「+ 신규 등록…」 — 계정 생성 모달 뒤 세 select 에 항목 삽입·자동 선택
- POST /admin/members 가 이름·직위·부서 수신, 계정 없으면 status=PENDING 으로 생성
  (로그인 불가 비밀번호). 700줄 제한으로 B01_Dashboard_Repository_Members.py 분리
- 회사 등록 모달에 로고 파일 칸, PUT /company/logo 신설, 회사 패널·회사 목록에서 변경
- 프로젝트 수정 모달의 「설계자 서명」 칸 제거 — signature_asset_id 는 null 로 고정 저장
- 검증(5174 실조작): 신규 등록 구성원 2→3(id 6 PENDING)·select 자동 선택, 서명 자산
  [4,'검증신규 서명',user_id 6] 생성, 회사 logo_asset_id None→1, 검증 자산·계정 정리.
  pytest 143 passed, tsc·ruff·prettier 통과

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

318 lines
11 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";
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>;
}
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 render = (): void => {
preview.hidden = !selected;
if (selected) preview.src = companyAssetFileUrl(selected.id);
name.textContent = selected ? selected.label : "(없음)";
};
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);
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";
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: close }));
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);
}