Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
101 lines
4.0 KiB
TypeScript
101 lines
4.0 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Base_Quote.ts
|
|
* 자재단가대비표 「견적서」 칸 작은 모달(PLAN 166-4) — 자재 줄 하나의 견적서 여러 파일 ·
|
|
* 목록 · 올리기(여러 개) · 내려받기 · 지우기. 파일은 올리는 즉시 서버에 둠([저장]과 따로).
|
|
*
|
|
* 길 = `B09_Estimation_Router_Quotes.py` · 바뀐 목록은 onChange(이름들)로 표 칸 「n 파일」에 반영
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
|
import { openModal } from "@ui/ui_template_modal";
|
|
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
|
|
|
interface QuoteFile {
|
|
name: string;
|
|
size: number;
|
|
at: string;
|
|
}
|
|
|
|
const url = (key: string, name?: string): string => {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
|
const q = new URLSearchParams({ key, ...(name ? { name } : {}) });
|
|
return `${API_BASE_URL}/projects/${projectId}/estimation/quotes${name ? "/file" : ""}?${q}`;
|
|
};
|
|
|
|
async function call(input: string, init: RequestInit = {}): Promise<Response> {
|
|
const response = await fetch(input, { credentials: "include", ...init });
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
|
throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function download(key: string, name: string): Promise<void> {
|
|
const blob = await (await call(url(key, name))).blob();
|
|
const a = el("a", { attrs: { href: URL.createObjectURL(blob), download: name } });
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
/** 견적서 모달 — label = 「품명 규격」 */
|
|
export function openQuotes(key: string, label: string, onChange: (names: string[]) => void): void {
|
|
openModal({
|
|
title: `견적서 — ${label}`,
|
|
closeLabel: "닫기",
|
|
mount: (body) => {
|
|
const list = el("ul", { className: "b09-quotes" });
|
|
const show = (files: QuoteFile[]): void => {
|
|
onChange(files.map((f) => f.name));
|
|
list.replaceChildren(
|
|
...(files.length
|
|
? files.map((f) =>
|
|
el("li", {
|
|
children: [
|
|
createButton({
|
|
label: `${f.name} (${Math.ceil(f.size / 1024).toLocaleString()} KB)`,
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => void download(key, f.name).catch(fail),
|
|
}),
|
|
createButton({
|
|
label: "지우기",
|
|
variant: "danger",
|
|
size: "sm",
|
|
onClick: () =>
|
|
void call(url(key, f.name).replace("/file?", "?"), { method: "DELETE" })
|
|
.then((r) => r.json())
|
|
.then(show, fail),
|
|
}),
|
|
],
|
|
}),
|
|
)
|
|
: [el("li", { text: "올린 견적서 없음" })]),
|
|
);
|
|
};
|
|
const fail = (error: unknown): void =>
|
|
showToast(`하지 못했습니다 — ${String(error)}`, "error");
|
|
const input = el("input", { attrs: { type: "file", multiple: "" } });
|
|
input.addEventListener("change", () => {
|
|
const form = new FormData();
|
|
for (const file of Array.from(input.files ?? [])) form.append("files", file);
|
|
input.value = "";
|
|
void call(url(key), { method: "POST", body: form })
|
|
.then((r) => r.json())
|
|
.then(show, fail);
|
|
});
|
|
const pick = createButton({
|
|
label: "견적서 올리기(여러 개)",
|
|
variant: "filled",
|
|
size: "sm",
|
|
onClick: () => input.click(),
|
|
});
|
|
input.hidden = true;
|
|
body.append(pick, input, list);
|
|
void call(url(key))
|
|
.then((r) => r.json())
|
|
.then(show, fail);
|
|
},
|
|
});
|
|
}
|