- 설계내역서 도구 줄 끝 단추 → 파일 고름 → 창 한 번(넘겨받기 · 단가만 · 이름 같은 자동 줄 붙임 · 남는 자동 줄 지움) → [불러오기] = 화면 캐시(저장 안 함 · [저장]해야 남음) - 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록 · 없으면 닫고 알림 · DRM = 「읽을 수 없음」 - 단가만 갈래 = 불러온 수량과 10 % 넘게 다른 수량 칸 붉게 · 풍선 = 불러온 수량 - 시험 243-1(표 만드는 길 목록 = 불러오기 창 표 더함 · 보기 전용) · 200-2(도구 줄 배선 = 불러오기 단추 끝) 고침 — 낡은 글자 대조 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
7.9 KiB
TypeScript
156 lines
7.9 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Bill.ts
|
|
* B09 설계내역서 탭(PLAN 145-3 · 184-3) — 실무 엑셀 꼴 칸(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
|
*
|
|
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
|
|
* 초기값 = 기본 로직이 B08 수량으로 짠 줄 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(184 · `…_UI_Tabs_Bill_Edit`)
|
|
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
|
|
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
|
|
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
|
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
|
|
* 단가연결 칸(램프) = 항목 줄(자재 · 조달수수료 빼고) · 램프 누름 = 우클릭 [연결] 과 같은 메뉴
|
|
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
|
|
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
|
|
* 오른쪽 재료 창고(703-1) = `…_UI_Store`(두 번 누름 · 끌어 놓기 = 단가연결)
|
|
* 불러온 수량(707-1 src_qty) = 수량 칸 풍선 · 지금 수량과 10 % 넘게 다르면 붉은 칸(단가만 갈래)
|
|
* ========================================================================== */
|
|
|
|
import type { DataRow } from "@ui/ui_template_data_table";
|
|
import type { Row } from "./B09_Estimation_Calc_Base";
|
|
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
|
import {
|
|
billMenu,
|
|
dropMove,
|
|
editCell,
|
|
linkable,
|
|
openLinkMenu,
|
|
type BillTable,
|
|
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold";
|
|
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;
|
|
const bill: Row = sheets.bill || { rows: [], totals: null };
|
|
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
|
|
const raw: DataRow[] = bill.rows;
|
|
// 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N)
|
|
const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결");
|
|
const rows: DataRow[] = raw.map((r: Row) => ({
|
|
...r,
|
|
name: r.no ? `${r.no} ${r.name}` : r.name,
|
|
connect: linkable(r) ? `${linkText(r)} ▾` : "",
|
|
}));
|
|
const direct: Row = bill.totals?.direct || {};
|
|
const pair = (key: string, label: string) => [
|
|
money(`price_${key}`, "단가", [label]),
|
|
money(key, "금액", [label]),
|
|
];
|
|
/** 고친 칸 — 셈 줄에 자동 값(auto_칸)이 있음 · 연결 칸 = auto_ref */
|
|
const edited = (r: DataRow | undefined, key: string): boolean =>
|
|
!!r && `auto_${key === "connect" ? "ref" : key}` in r;
|
|
return {
|
|
columns: [
|
|
text("name", "공종 · 명칭", 220),
|
|
text("spec", "규격", 160),
|
|
qty("qty", "수량", 3),
|
|
text("unit", "단위", 56),
|
|
...pair("sum", "합계"),
|
|
...pair("labor", "노무비"),
|
|
...pair("material", "재료비"),
|
|
...pair("expense", "경비"),
|
|
{ ...text("connect", "단가\n연결", 44), title: "단가연결" },
|
|
text("note", "비고", 200),
|
|
],
|
|
rows,
|
|
total: { name: "순공사비 계(관급 제외)", ...direct },
|
|
frozen: 1,
|
|
hint:
|
|
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 ● 을 누르면 일위대가 · 단가산출 연결" +
|
|
(direct.blank
|
|
? ` · 금액 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만`
|
|
: ""),
|
|
editOnDouble: true,
|
|
editable: (i, key) => {
|
|
const r = rows[i];
|
|
if (!r) return false;
|
|
if (r.key === "fee") return key === "qty";
|
|
if (r.kind !== "item") return key === "name";
|
|
return key === "qty" || TEXT_KEYS.includes(key);
|
|
},
|
|
onEdit: (i, key, value) => {
|
|
const r = rows[i];
|
|
if (!r) return;
|
|
// 번호 = 셈이 붙임 — 고친 이름 앞 번호는 뗌
|
|
const head = `${r.no} `;
|
|
if (key === "name" && r.no && value.startsWith(head)) value = value.slice(head.length);
|
|
if (key !== "qty") return editCell(ctx, raw, i, key, value.trim());
|
|
const v = parseNumber(value);
|
|
if (v === undefined) return;
|
|
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
|
|
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
|
},
|
|
cellClass: (i, key) => {
|
|
const r = rows[i];
|
|
const lamp =
|
|
key === "connect" && r && linkable(raw[i])
|
|
? `ui-data-table__lamp ui-data-table__lamp--only b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}`
|
|
: "";
|
|
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];
|
|
if (r && key === "connect" && linkable(raw[i])) {
|
|
const auto = (r.auto_ref as Row | null)?.key ?? "빈칸";
|
|
const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : "";
|
|
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 ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
|
},
|
|
rowClass: (i) => {
|
|
const r = rows[i];
|
|
if (!r) return null;
|
|
// 240 폴더 줄 = 처음 · 레벨 고쳐 생긴 것 모두 같은 줄 꼴(글자 크기 = 레벨 · 공용 is-lvN)
|
|
const title = r.kind === "item" ? "" : "is-title";
|
|
return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null;
|
|
},
|
|
// 228-1 레벨 = 셈 나무 깊이(들여 · 내어쓰기 · 끌기 = 다시 셈 · 다시 그림) · 글꼴 = 레벨 · 묶음(빈 공종도)
|
|
level: {
|
|
key: "name",
|
|
of: (i) => rows[i]?.level as number | undefined,
|
|
group: (i) => rows[i]?.kind !== "item",
|
|
tree: true,
|
|
},
|
|
menu: (i) => billMenu(ctx, raw, i),
|
|
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
|
|
fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개
|
|
side: (table) => attachBillStore(ctx, raw, table), // 703-1 오른쪽 재료 창고
|
|
onCell: (i, key) => {
|
|
if (key === "connect") openLinkMenu(ctx, raw, i);
|
|
},
|
|
link: (i, key) => {
|
|
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
|
|
const ref = rows[i]?.ref as Row | null | undefined;
|
|
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
|
|
},
|
|
};
|
|
};
|