Files
Aislo/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts
T
eomsangdonandClaude Opus 5 00d30587b3 feat(B01,B07): 담당자 회사 구성원 지정 + 로고·서명 회사 공유 자산 모달
- B01 프로젝트 수정: 과업책임자·분야별책임자·설계자를 회사 구성원 목록(select)에서
  지정. 회사 로고·설계자 서명 칸은 누르면 등록 목록 모달(선택·삭제·신규 추가 업로드).
- 백엔드: `company_assets`(013) CRUD 라우트 신설 — 목록·업로드(Form+File, 2MB·확장자
  검사)·수정·소프트 삭제·파일 서빙. `_scope_company` 로 SYSTEM_ADMIN 만 타 회사 지정,
  `_check_project_refs` 로 담당자·자산이 같은 회사·같은 종류인지 검사(400).
- projects 조회·수정에 pm/field_lead/designer/logo_asset/signature_asset 5칸 합류.
  중복 3곳을 `_project_rows` 로 묶어 700줄 제한 유지.
- B07 표제란: 로고·서명 출처를 `companies.logo_path`/`users.signature_path` 에서
  프로젝트가 고른 `company_assets` 로 전환(삭제된 자산은 그림째 제외).
- 검증: pytest 143 통과(신규 4), tsc·ruff·prettier 통과. 공용 브라우저 사용자 경로
  (업로드→선택→저장→재열기) 및 종단도 API 표제란 글자 4건·그림 2건 수치 확인.

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

185 lines
6.9 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;
}
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,
): AssetFieldHandle {
let list = assets.filter((asset) => asset.kind === kind);
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();
}),
});
row.append(preview, name, pick);
root.append(caption, row);
return { root, value: () => selected?.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,
): 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 = kind === "SIGNATURE";
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
const add = createButton({
label: "올리고 선택",
onClick: async () => {
const chosen = file.input.files?.[0];
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
if (!chosen) return file.setError("그림 파일을 고르세요.");
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(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, mine, add, actions);
modal.append(panel);
document.body.append(modal);
}