- 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음) - 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만 - 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음) - 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임 - 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
7.6 KiB
TypeScript
177 lines
7.6 KiB
TypeScript
/* =============================================================================
|
|
* 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<Row> {
|
|
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;
|
|
}
|