/* ============================================================================= * B09_Estimation_UI_Import.ts * 경쟁사 결과물 불러오기 단추 · 확인 창(PLAN 707-2 요율 · 707-3 기초 단가표) — 저장 안 함. * * 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] · * 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기] * 길 = `B09_Estimation_Router_Import.py`(file | file_key · user = 화면 캐시) → 확인 창(짝 표 · 식 다름 · * 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음(바로 다시 셈) · [저장] 때 서버가 정본으로 셈 * 올린 파일 = 서버가 한 시간 기억(file_key) — 같은 파일로 요율 · 단가표를 다시 올리지 않음 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { pickFiles } from "@ui/ui_template_drop"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { currentProjectId } from "../A00_Common/b_project_switch"; import type { Row } from "./B09_Estimation_Calc_Base"; import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; type Kind = "rates" | "prices"; const TABS: Partial> = { total: "rates", cost: "rates", labor: "prices", machine: "prices", compare: "prices", }; const LABEL: Record = { rates: "엑셀에서 요율 불러오기", prices: "엑셀에서 단가표 불러오기", }; const PART: Record = { labor: "노임", material: "자재", machine: "기계" }; /** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */ let last: { key: string; name: string } | null = null; async function post(kind: Kind, ctx: TabContext, file: File | null): Promise { const form = new FormData(); if (file) form.append("file", file); else if (last) form.append("file_key", last.key); form.append("user", JSON.stringify(ctx.user)); const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`; const response = await fetch(url, { method: "POST", body: form, credentials: "include" }); const body = (await response.json().catch(() => null)) as Row | null; if (!response.ok) { if (response.status === 404 && !file) last = null; // 지난 파일 = 다시 고름 throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); } last = { key: String(body!.file_key), name: String(body!.name) }; return body!; } const won = (v: unknown): string => typeof v === "number" ? Math.round(v).toLocaleString("en-US") : ""; function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } { const check: Row[] = got.check?.lines || []; const ours = Object.fromEntries(check.map((ln) => [ln.key, ln])); const table = (got.rows as Row[]) .filter((r) => r.key && r.key !== "subtotal") .map((r, i) => ({ key: String(i), name: r.name, rate: r.rate, excel: r.amount, ours: ours[r.key]?.ours ?? null, // 산업안전보건관리비 A · B 두 줄(EST) = 작은 쪽만 우리 식과 견줌 same: !ours[r.key] ? "" : Math.abs((r.amount ?? NaN) - ours[r.key].ours) < 1 ? "같음" : ours[r.key].same ? "안 고른 쪽" : "식 다름", })); const grand = got.check?.grand; const lines = [ `율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${won(grand)} / 엑셀 ${won( got.check?.excel_grand, )}`, ...(got.notes as string[]), ]; const columns = [ text("name", "행 이름", 180), qty("rate", "율(%)", 4), money("excel", "엑셀 금액"), money("ours", "우리 식"), text("same", "대조", 72), ]; return { table, lines, columns }; } function pricesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } { const show = (v: unknown): string => v && typeof v === "object" ? ["labor", "material", "expense"].map((p) => won((v as Row)[p])).join(" / ") : won(v); const table = (got.linked as Row[]).map((x, i) => ({ key: String(i), part: PART[x.kind] || x.kind, from: x.from, name: x.name, excel: show(x.excel), book: show(x.book), same: x.same ? "같음" : "덮음", })); const counts: Row = got.counts || {}; const lines = Object.entries(PART).map(([k, name]) => { const c: Row = counts[k] || {}; const miss: string[] = got.missing?.[k] || []; return `${name} — 읽음 ${c.read ?? 0} · 이음 ${c.linked ?? 0}(덮음 ${c.covered ?? 0}) · 이 공사 줄 ${ c.book ?? 0 } · 짝 없음 ${c.missing ?? 0}${miss.length ? `(${miss.slice(0, 8).join(", ")}${miss.length > 8 ? " …" : ""})` : ""}`; }); const columns = [ text("part", "갈래", 56), text("from", "엑셀 이름", 200), text("name", "이 공사 줄", 160), text("excel", "엑셀 단가", 150), text("book", "이 공사 단가", 150), text("same", "", 56), ]; return { table, lines, columns }; } function openResult(kind: Kind, ctx: TabContext, got: Row): void { const view = kind === "rates" ? ratesView(got) : pricesView(got); openModal({ title: `${LABEL[kind]} — ${got.name}`, closeLabel: "닫기", width: "960px", mount: (body, close, actions) => { const apply = createButton({ label: "적용", variant: "filled", size: "sm", onClick: () => { ctx.setUser({ ...ctx.user, ...got.patch }); showToast("적용 — [저장] 해야 남음", "success"); close(); }, }); const other = createButton({ label: "다른 파일", variant: "ghost", size: "sm", onClick: () => { close(); pick(kind, ctx); }, }); actions.append(other, apply); const table = createDataTable({ columns: view.columns, rows: view.table, empty: "" }); body.append(...view.lines.map((t) => el("p", { text: t })), table.root); }, }); } const fail = (error: unknown): void => showToast(`불러오지 못함 — ${String(error)}`, "error"); function pick(kind: Kind, ctx: TabContext): void { pickFiles( { accept: ".xlsx,.xls" }, ([file]) => void post(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail), ); } /** 탭 도구 자리 단추 — 불러오기 없는 탭 = 빈 목록 */ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { const kind = TABS[tab]; if (!kind) return []; const button = createButton({ label: LABEL[kind], variant: "ghost", size: "sm", onClick: () => last ? void post(kind, ctx, null).then( (got) => openResult(kind, ctx, got), () => pick(kind, ctx), ) : pick(kind, ctx), }); if (last) button.title = `마지막 파일 ${last.name} 로 · 다른 파일은 창에서`; return [button]; }