feat(B09 설계내역서): [엑셀 내역 불러오기] 창 — 수량 갈래 · 두 번 셈 막음 · 어긋난 줄 목록 (PLAN 707-1)
- 설계내역서 도구 줄 끝 단추 → 파일 고름 → 창 한 번(넘겨받기 · 단가만 · 이름 같은 자동 줄 붙임 · 남는 자동 줄 지움) → [불러오기] = 화면 캐시(저장 안 함 · [저장]해야 남음) - 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록 · 없으면 닫고 알림 · DRM = 「읽을 수 없음」 - 단가만 갈래 = 불러온 수량과 10 % 넘게 다른 수량 칸 붉게 · 풍선 = 불러온 수량 - 시험 243-1(표 만드는 길 목록 = 불러오기 창 표 더함 · 보기 전용) · 200-2(도구 줄 배선 = 불러오기 단추 끝) 고침 — 낡은 글자 대조 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/* =============================================================================
|
||||
* 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: 56 },
|
||||
{ key: "이름", label: "명칭", width: 200 },
|
||||
{ key: "엑셀", label: "엑셀 금액", kind: "number", width: 110 },
|
||||
{ key: "우리", label: "우리 금액", kind: "number", width: 110 },
|
||||
{ key: "까닭", label: "까닭", width: 160 },
|
||||
],
|
||||
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);
|
||||
done(got.payload, merged(draft, got.user));
|
||||
const n = got.알림 || {};
|
||||
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`;
|
||||
const off: Row[] = got.어긋남 || [];
|
||||
if (!off.length) {
|
||||
close();
|
||||
return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
|
||||
}
|
||||
run.remove();
|
||||
body.replaceChildren(
|
||||
createText(head, "success", 14),
|
||||
createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14),
|
||||
differences(off),
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
@@ -301,3 +301,16 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
|
||||
.ui-side-panels-row > .ui-side-panels.b09-store__side {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 707-1 엑셀 내역 불러오기 창 — 고르기 줄 묶음 · 단가만 갈래 수량 차이 큰 칸(불러온 수량과 10 % 넘게 다름) */
|
||||
.b09-import__choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody td.is-qty-gap {
|
||||
color: var(--color-danger);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check";
|
||||
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
|
||||
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
|
||||
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
|
||||
import { createBillImport } from "./B09_Estimation_UI_Import";
|
||||
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
|
||||
import {
|
||||
DRAFT,
|
||||
@@ -285,7 +286,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
);
|
||||
if (range) unpickOnRelease(table.root);
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
if (level) bars.setTools(importer ? [...level.tools, importer] : level.tools);
|
||||
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
|
||||
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
|
||||
const foot = hint ?? level?.guide;
|
||||
@@ -301,6 +302,18 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
// 707-1 [엑셀 내역 불러오기] = 설계내역서 도구 줄 끝 · 받은 캐시 → 원가 자료와 함께 다시 셈(저장 안 함)
|
||||
const importer = projectId
|
||||
? createBillImport(
|
||||
projectId,
|
||||
() => user,
|
||||
(next, made) => {
|
||||
writeState("b09-estimation", made, projectId);
|
||||
apply(next, false);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
const apply = (next: Row, fromServer: boolean): void => {
|
||||
payload = next;
|
||||
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
|
||||
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
|
||||
* 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결)
|
||||
* 불러온 수량(707-1 src_qty) = 수량 칸 풍선 · 지금 수량과 10 % 넘게 다르면 붉은 칸(단가만 갈래)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -31,6 +32,14 @@ import { attachBillStore } from "./B09_Estimation_UI_Store";
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
/** 폴더 줄 수식 칸 — 자손 합(셈) */
|
||||
const SUM_KEYS = ["sum", "labor", "material", "expense"];
|
||||
/** 707-1 불러온 수량과 지금 수량 차이 — 이 비율 넘으면 붉은 칸 */
|
||||
const QTY_GAP = 0.1;
|
||||
const qtyGap = (r: Row | undefined): boolean =>
|
||||
!!r &&
|
||||
r.kind === "item" &&
|
||||
typeof r.src_qty === "number" &&
|
||||
typeof r.qty === "number" &&
|
||||
Math.abs(r.qty - r.src_qty) > QTY_GAP * Math.abs(r.src_qty);
|
||||
|
||||
export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const { sheets, user, setUser } = ctx;
|
||||
@@ -99,7 +108,8 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
key === "connect" && r && linkable(raw[i])
|
||||
? `ui-data-table__lamp ui-data-table__lamp--only b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}`
|
||||
: "";
|
||||
return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null;
|
||||
const gap = key === "qty" && qtyGap(r) ? "is-qty-gap" : "";
|
||||
return [lamp, edited(r, key) ? "is-edited" : "", gap].filter(Boolean).join(" ") || null;
|
||||
},
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
@@ -109,6 +119,8 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
return linkText(raw[i]) + back;
|
||||
}
|
||||
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
|
||||
if (key === "qty" && typeof r?.src_qty === "number" && !edited(r, key))
|
||||
return `불러온 수량 ${r.src_qty}`;
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
||||
|
||||
@@ -200,7 +200,8 @@ def test_셸_배선():
|
||||
assert (
|
||||
"bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src
|
||||
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
|
||||
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
|
||||
# 229-1 단추 = 보조 탭 줄 · 707-1 [엑셀 내역 불러오기] = 그 끝
|
||||
assert "if (level) bars.setTools(importer ? [...level.tools, importer] : level.tools);" in src
|
||||
assert (
|
||||
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src
|
||||
and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src
|
||||
|
||||
@@ -112,15 +112,18 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본():
|
||||
calls = [
|
||||
p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8")
|
||||
]
|
||||
# 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침)
|
||||
# 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) ·
|
||||
# 702-1 펼침 표 = 제 표(두 번 눌러 고침)
|
||||
assert calls == [
|
||||
"B09_Estimation_UI_Import.ts",
|
||||
"B09_Estimation_UI_Popup.ts",
|
||||
"B09_Estimation_UI_Shell.ts",
|
||||
"B09_Estimation_UI_Tabs_Bill_Fold.ts",
|
||||
]
|
||||
fold = (ROOT / "B09_Estimation" / calls[2]).read_text("utf-8")
|
||||
assert "editable" not in (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8")
|
||||
fold = (ROOT / "B09_Estimation" / calls[3]).read_text("utf-8")
|
||||
assert "editOnDouble: true," in fold
|
||||
shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8")
|
||||
shell = (ROOT / "B09_Estimation" / calls[2]).read_text("utf-8")
|
||||
assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳
|
||||
builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8")
|
||||
assert "editOnDouble: spec.editOnDouble ?? true," in builders
|
||||
|
||||
Reference in New Issue
Block a user