/* ============================================================================= * B09_Estimation_UI_Import.ts * B09 설계내역서 [엑셀 내역 불러오기](PLAN 707-1) — 경쟁사 내역 엑셀(STmate · EST) 줄을 화면 캐시로. * * 누름 = 파일 고름 → 서버 읽기 · 미리 보기(`POST …/estimation/import/bill`) → 창 하나에서 한 번 고름 * 수량 갈래(가 넘겨받기 · 나 단가만) · 이름 같은 자동 줄에 붙임(두 번 셈 막음) · 남는 자동 줄 지움 * [불러오기] = `POST …/import/bill/apply` → 받은 원가 자료 · 새 캐시(내역 덮개 · 조달수수료율) → [저장]해야 남음 * 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림 * DRM 파일 = 서버 400 「읽을 수 없음」 알림 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { createDataTable } from "@ui/ui_template_data_table"; import { pickFiles } from "@ui/ui_template_drop"; import { createButton, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR"); async function post(projectId: string, path: string, body: BodyInit, json = false): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/import/${path}`, { method: "POST", credentials: "include", ...(json ? { headers: { "Content-Type": "application/json" } } : {}), body, }); const got = (await response.json().catch(() => null)) as Row | null; if (!response.ok || !got) { const detail = got?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); } return got; } /** 받은 캐시 = 지금 캐시 + 내역 덮개 · 조달수수료율(서버가 빈 때만 채움) */ function merged(user: Row, made: Row): Row { const pct = made.estimate?.procurement_fee_pct; const estimate = { ...(user.estimate || {}), ...(pct != null && { procurement_fee_pct: pct }) }; return { ...user, bill: made.bill, estimate }; } function differences(rows: Row[]): HTMLElement { return createDataTable({ columns: [ { key: "행", label: "엑셀 행", kind: "number", width: 48 }, { key: "이름", label: "명칭", width: 150 }, { key: "엑셀", label: "엑셀 금액", kind: "number", width: 96 }, { key: "우리", label: "우리 금액", kind: "number", width: 96 }, { key: "까닭", label: "까닭", width: 130 }, ], rows, }).root; } /** [엑셀 내역 불러오기] 단추 — user = 지금 화면 캐시 · done = 받은 원가 자료 + 새 캐시 */ export function createBillImport( projectId: string, user: () => Row, done: (payload: Row, user: Row) => void, ): HTMLButtonElement { const open = (seen: Row, mode: { value: string }): void => { const total = seen.합계 || {}; const lines = [ `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, `엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), seen.이음 ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` : "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)", ]; const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { radio: "b09-import-mode", value: "take", checked: mode.value === "take", }); const price = createCheckbox( "단가만 — 수량 산출 수량을 씀(불러온 수량은 비교 · 차이 큰 줄 붉게)", { radio: "b09-import-mode", value: "price", checked: mode.value === "price", }, ); const attach = createCheckbox(`이름이 같은 자동 줄 ${seen.같은이름} 개에 붙임(두 번 셈 막음)`, { checked: seen.같은이름 > 0, disabled: !seen.같은이름, }); const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, { disabled: !seen.남는자동, }); const run = createButton({ label: "불러오기", variant: "filled" }); run.dataset.b09ImportRun = ""; openModal({ title: "엑셀 내역 불러오기", closeLabel: "닫기", width: "40rem", mount: (body, close, actions) => { body.append( ...lines.map((t) => createText(t, "muted", 14)), el("div", { className: "b09-import__choices", children: [take.root, price.root] }), el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }), ); actions.append(run); run.addEventListener("click", async () => { mode.value = price.input.checked ? "price" : "take"; const draft = user(); const ask = { file_key: seen.file_key, mode: mode.value, attach: attach.input.checked, drop: drop.input.checked, user: draft, }; showLoadingOverlay(); try { const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); done(got.payload, merged(draft, got.user)); const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); // 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창 const result = (): void => void openModal({ title: "엑셀 내역 불러오기 — 결과", closeLabel: "닫기", width: "40rem", mount: (box) => box.append( createText(head, "success", 14), createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), differences(off), ), }); requestAnimationFrame(() => requestAnimationFrame(result)); } catch (error) { showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error"); } finally { hideLoadingOverlay(); } }); }, }); }; const mode = { value: "take" }; const button = createButton({ label: "엑셀 내역 불러오기", variant: "ghost", onClick: () => pickFiles({ accept: ".xls,.xlsx" }, ([file]) => { const form = new FormData(); form.append("file", file); form.append("user", JSON.stringify(user())); showLoadingOverlay(); post(projectId, "bill", form) .then((seen) => open(seen, mode)) .catch((error: unknown) => showToast(`읽지 못함 — ${error instanceof Error ? error.message : error}`, "error"), ) .finally(hideLoadingOverlay); }), }); button.dataset.b09Import = ""; return button; }