- 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>
185 lines
6.9 KiB
TypeScript
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);
|
|
}
|