Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_Tabs_Base.ts
|
||
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
|
||
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
|
||
* 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 ·
|
||
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
|
||
*
|
||
* 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 →
|
||
* `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) ·
|
||
* 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력
|
||
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
|
||
* 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶)
|
||
* · 출처 묶음 = 단가 칸 하나 · 출처 뜻(M01 면수 · 유가 기준일)은 단가 칸 호버(185-4 `sourceInfo`)
|
||
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
|
||
* ========================================================================== */
|
||
|
||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||
import type { DataRow } from "@ui/ui_template_data_table";
|
||
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
|
||
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
|
||
import {
|
||
money,
|
||
parseNumber,
|
||
qty,
|
||
text,
|
||
type TabBuild,
|
||
type TabId,
|
||
type TabTable,
|
||
} from "./B09_Estimation_UI_Tabs";
|
||
|
||
const LINK_HINT = "줄을 누르면 단가를 낸 탭 · 줄로 이동";
|
||
|
||
/** 단가 줄 표(노무비 · 경비목록표) — 호표 · 명칭 · 규격 · 단위 · 단가 · 비고(출처) */
|
||
function priceList(rows: Row[], unitPrice: string, hint: string): TabTable {
|
||
return {
|
||
columns: [
|
||
qty("no", "호표", 0),
|
||
text("name", "명칭", 200),
|
||
text("spec", "규격", 140),
|
||
text("unit", "단위", 56),
|
||
money("price", unitPrice),
|
||
text("source", "비고", 220),
|
||
],
|
||
rows: rows.map((r) => ({ ...r })),
|
||
frozen: 2,
|
||
hint,
|
||
};
|
||
}
|
||
|
||
export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||
priceList(
|
||
sheets.labor || [],
|
||
"단가(원/인)",
|
||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
|
||
);
|
||
|
||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||
priceList(
|
||
sheets.expense || [],
|
||
"기준가(천원)",
|
||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
|
||
);
|
||
|
||
function madeText(payload: Row): string {
|
||
return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘";
|
||
}
|
||
|
||
export const buildBasis: TabBuild = ({ sheets }): TabTable => {
|
||
const basis: Row = sheets.basis || { fx: [], drivers: [] };
|
||
const rows: DataRow[] = [
|
||
...basis.fx.map((f: Row) => ({
|
||
part: "1. 환율",
|
||
key: f.key,
|
||
name: f.name,
|
||
spec: f.date,
|
||
unit: f.unit,
|
||
day: null,
|
||
factor: "",
|
||
value: f.value,
|
||
})),
|
||
...basis.drivers.map((d: Row) => ({
|
||
part: "2. 인건비",
|
||
key: d.key,
|
||
name: d.name,
|
||
spec: `직종 ${d.key}`,
|
||
unit: "원/시간",
|
||
day: d.day,
|
||
factor: d.factor,
|
||
value: d.hour,
|
||
labor_key: d.labor_key,
|
||
})),
|
||
];
|
||
return {
|
||
columns: [
|
||
text("part", "구분", 90),
|
||
text("name", "명칭", 160),
|
||
text("spec", "규격 · 기준일", 120),
|
||
text("unit", "단위", 80),
|
||
money("day", "일당(원)"),
|
||
text("factor", "시간 환산", 160),
|
||
money("value", "값", undefined, 2),
|
||
],
|
||
rows,
|
||
frozen: 2,
|
||
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
|
||
link: (i) => {
|
||
const row = rows[i];
|
||
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
|
||
},
|
||
};
|
||
};
|
||
|
||
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: null };
|
||
|
||
/** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 ·
|
||
* `pick(s)` = 그 출처 · null = 자동 */
|
||
function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] {
|
||
const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0);
|
||
const label = (s: string): string =>
|
||
`${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` +
|
||
(s === r.lowest_from ? " · 최저" : "");
|
||
return [
|
||
...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]),
|
||
"-",
|
||
[`${r.pick || r.applied_from === "사용자" ? "" : "✓ "}최저(자동)`, () => pick(null)],
|
||
];
|
||
}
|
||
|
||
/** 출처 단가 칸 호버 글(185-4) — M01 면수 「670(26.7)」 · 유가 기준일 「2026-08-14」 를 뜻으로 풂 ·
|
||
* 값 없는(0 · 빈칸) 칸 = 없음 · 값 · 저장 꼴은 그대로 */
|
||
export function sourceInfo(r: Row, s: string, files = 0): string | null {
|
||
if (!(Number(r.prices?.[s]) > 0)) return null;
|
||
const page = String(r.pages?.[s] ?? "").trim();
|
||
if (s === "관급") return "관급 · 조달청 계약 단가";
|
||
if (s === "견적") return `견적 · 수기 입력${files ? ` · 견적서 ${files} 파일` : ""}`;
|
||
if (s === "유가") {
|
||
const from = String(r.source ?? "");
|
||
if (!page) return "유가";
|
||
if (from.includes("오피넷")) return `유가 · 오피넷 · ${page} 수집`;
|
||
if (from.includes("한전")) return `전기요금 · 한전 · ${page} 시행`;
|
||
return `유가 · ${page} 기준`;
|
||
}
|
||
// 면수 = [부(부록) · 하(하권) · n권] 쪽(연.월) — 예 「부536(26.7)」 = 2026-07 호 부록 536쪽
|
||
const m = /^(부|하|\d+권)?(\d+)\((\d{2})\.(\d{1,2})\)$/.exec(page);
|
||
if (!m) return page ? `${s} · ${page}` : `${s} · 면수 없음`;
|
||
const book = m[1] === "부" ? "부록 " : m[1] === "하" ? "하권 " : m[1] ? `${m[1]} ` : "";
|
||
return `${s} · 20${m[3]}-${m[4].padStart(2, "0")} · ${book}${m[2]}쪽`;
|
||
}
|
||
|
||
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||
const rows: Row[] = sheets.compare || [];
|
||
const out: DataRow[] = rows.map((r) => {
|
||
const files: string[] = (user.materials || {})[r.key]?.files || [];
|
||
// 185-5 견적서 글자 = 견적 단가 수기 줄만(올린 파일이 남은 줄도 · 나머지 빈칸)
|
||
const quoted = r.prices["견적"] != null || files.length > 0;
|
||
const row: DataRow = {
|
||
no: r.no,
|
||
key: r.key,
|
||
name: r.name,
|
||
spec: r.spec,
|
||
unit: r.unit,
|
||
lowest: r.lowest,
|
||
applied: r.applied,
|
||
applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""),
|
||
supply: r.supply ?? "",
|
||
files: !quoted ? "" : files.length ? `${files.length} 파일` : "첨부",
|
||
};
|
||
for (const s of SOURCES) row[`p:${s}`] = r.prices[s];
|
||
return row;
|
||
});
|
||
const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) });
|
||
const put = (key: string, cell: Row): void => {
|
||
const materials = { ...(user.materials || {}) };
|
||
if (Object.keys(cell).length) materials[key] = cell;
|
||
else delete materials[key];
|
||
setUser({ ...user, materials });
|
||
};
|
||
return {
|
||
columns: [
|
||
qty("no", "번호", 0),
|
||
text("name", "품명", 180),
|
||
text("spec", "규격", 140),
|
||
text("unit", "단위", 56),
|
||
// 185-4 — 출처 = 단가 하나(출처 뜻은 단가 칸 호버) · 견적 = 단가 · 견적서
|
||
...SOURCES.flatMap((s) =>
|
||
s === "견적"
|
||
? [money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]
|
||
: [money(`p:${s}`, "단가", [s])],
|
||
),
|
||
money("lowest", "최저"),
|
||
money("applied", "적용 단가", ["적용"]),
|
||
text("applied_from", "출처", 80, ["적용"]),
|
||
text("supply", "관급 · 별도", 80),
|
||
],
|
||
rows: out,
|
||
frozen: 2,
|
||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)",
|
||
tip: (i, key) =>
|
||
key.startsWith("p:") && rows[i]
|
||
? sourceInfo(
|
||
rows[i],
|
||
key.slice(2),
|
||
((user.materials || {})[rows[i].key]?.files || []).length,
|
||
)
|
||
: null,
|
||
editable: (_i, key) => key === "p:견적",
|
||
onEdit: (i, key, value) => {
|
||
const row = out[i];
|
||
const v = parseNumber(value);
|
||
if (!row || key !== "p:견적" || v === undefined) return;
|
||
const cell = mine(String(row.key));
|
||
if (v === null) delete cell.quote;
|
||
else cell.quote = v;
|
||
put(String(row.key), cell);
|
||
},
|
||
// 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴
|
||
cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null),
|
||
choices: (i, key) => {
|
||
const r = rows[i];
|
||
if (key !== "applied" || !r) return null;
|
||
return appliedChoices(r, (s) => {
|
||
const cell = mine(r.key);
|
||
delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂
|
||
if (s) cell.pick = s;
|
||
else delete cell.pick;
|
||
put(r.key, cell);
|
||
});
|
||
},
|
||
onCell: (i, key) => {
|
||
const row = out[i];
|
||
const r = rows[i];
|
||
if (!row || !r) return;
|
||
const cell = mine(String(row.key));
|
||
if (key === "files" && row.files) {
|
||
const label = `${r.name} ${r.spec}`.trim();
|
||
return openQuotes(r.key, label, (names) => {
|
||
const now = mine(r.key);
|
||
if (JSON.stringify(now.files || []) === JSON.stringify(names)) return;
|
||
if (names.length) now.files = names;
|
||
else delete now.files;
|
||
put(r.key, now);
|
||
});
|
||
}
|
||
if (key !== "supply") return;
|
||
const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null;
|
||
if (next) cell.supply = next;
|
||
else delete cell.supply;
|
||
put(String(row.key), cell);
|
||
},
|
||
};
|
||
};
|
||
|
||
/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」
|
||
* (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */
|
||
function usesOf(sheets: Row): Map<string, string[]> {
|
||
const out = new Map<string, string[]>();
|
||
const add = (key: unknown, label: string): void => {
|
||
if (!key) return;
|
||
const list = out.get(String(key)) ?? [];
|
||
if (!list.includes(label)) out.set(String(key), [...list, label]);
|
||
};
|
||
const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void =>
|
||
(blocks || []).forEach((b, n) =>
|
||
(b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))),
|
||
);
|
||
scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`);
|
||
scan(sheets.sg, (b) => `산근 ${b.no}호표`);
|
||
scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`);
|
||
for (const r of sheets.bill?.rows || [])
|
||
if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대");
|
||
return out;
|
||
}
|
||
|
||
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
|
||
const uses = usesOf(sheets);
|
||
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
|
||
...r,
|
||
supply: r.supply ?? "",
|
||
uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""),
|
||
}));
|
||
return {
|
||
columns: [
|
||
qty("no", "호표", 0),
|
||
text("name", "명칭", 200),
|
||
text("spec", "규격", 160),
|
||
text("unit", "단위", 56),
|
||
qty("qty", "수량", 3),
|
||
money("price", "단가"),
|
||
money("amount", "총액"),
|
||
text("supply", "관급 · 별도", 80),
|
||
text("uses", "비고(쓰는 곳)", 240),
|
||
],
|
||
rows,
|
||
total: rows.length
|
||
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
|
||
: null,
|
||
frozen: 2,
|
||
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
|
||
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
|
||
};
|
||
};
|
||
|
||
export const buildMachine: TabBuild = ({ sheets }): TabTable => {
|
||
const rows: DataRow[] = (sheets.machine || []).map((r: Row) => ({ ...r }));
|
||
return {
|
||
columns: [
|
||
qty("no", "호표", 0),
|
||
text("name", "명칭", 200),
|
||
text("spec", "규격", 120),
|
||
text("unit", "단위", 56),
|
||
money("sum", "합계"),
|
||
money("labor", "노무비"),
|
||
money("material", "재료비"),
|
||
money("expense", "경비"),
|
||
],
|
||
rows,
|
||
frozen: 2,
|
||
hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`,
|
||
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
|
||
};
|
||
};
|
||
|
||
const KIND_TAB: Record<string, TabId> = {
|
||
expense: "expense",
|
||
labor: "basis",
|
||
material: "compare",
|
||
};
|
||
|
||
export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||
const rows: DataRow[] = [];
|
||
const links: Array<{ tab: TabId; key: string } | null> = [];
|
||
(sheets.machine_blocks || []).forEach((b: Row, n: number) => {
|
||
rows.push({
|
||
key: b.key,
|
||
name: `제 ${n + 1} 호표 ${b.name}`,
|
||
no: n + 1,
|
||
title: b.name, // 171-3 묶음 카드 머리
|
||
spec: b.spec,
|
||
unit: b.unit,
|
||
});
|
||
links.push(null);
|
||
for (const ln of b.lines) {
|
||
const at = (part: string): number | null => (ln.kind === part ? ln.price : null);
|
||
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
|
||
rows.push({
|
||
key: `${b.key}|${ln.name}`,
|
||
name: ` ${ln.name}`,
|
||
spec: ln.spec,
|
||
qty: ln.qty,
|
||
unit: ln.unit,
|
||
sum_price: ln.price,
|
||
sum: ln.amount,
|
||
labor_price: at("labor"),
|
||
labor: amt("labor"),
|
||
material_price: at("material"),
|
||
material: amt("material"),
|
||
expense_price: at("expense"),
|
||
expense: amt("expense"),
|
||
});
|
||
links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null);
|
||
}
|
||
rows.push({
|
||
key: `${b.key}|합계`,
|
||
name: " 합 계",
|
||
sum: b.sum,
|
||
labor: b.labor,
|
||
material: b.material,
|
||
expense: b.expense,
|
||
});
|
||
links.push(null);
|
||
});
|
||
const pair = (key: string, label: string) => [
|
||
money(`${key}_price`, "단가", [label], 1),
|
||
money(key, "금액", [label], 1),
|
||
];
|
||
return {
|
||
columns: [
|
||
text("name", "명칭", 220),
|
||
text("spec", "규격", 120),
|
||
qty("qty", "수량"),
|
||
text("unit", "단위", 56),
|
||
...pair("sum", "합계"),
|
||
...pair("labor", "노무비"),
|
||
...pair("material", "재료비"),
|
||
...pair("expense", "경비"),
|
||
],
|
||
rows,
|
||
frozen: 1,
|
||
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
|
||
link: (i) => links[i] ?? null,
|
||
};
|
||
};
|