Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts
T
eomsangdonandClaude Sonnet 5.5 88dd941d73 fix(B08·B09): 표 아래 안내 글 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0(236-1)
- B09 표 탭 안내 글 = 쓸모 있는 한 줄(lineHint) · 묶음 탭과 같은 줄임표
- B08 표 아래 안내 · 경고 줄 = footNote 한 줄 · 서버 알림은 한 줄로 이어 붙임
- 새 시험 test_236_1 · 창 높이별 창 끝까지 셈 헬퍼

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 19:09:56 +09:00

396 lines
15 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 {
blockLevel,
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)}) 기준 · 바꾸려면 [업데이트]`,
);
export const buildExpense: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.expense || [],
"기준가(천원)",
`굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`,
);
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,
mergeSame: ["part"],
hint: "인건비 줄을 누르면 노무비목록표로 이동",
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: "「적용 단가」 칸을 누르면 출처 고르기 · 「관급 · 별도」 칸을 누르면 구분 바꿈 · 견적 단가는 수기 입력",
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: 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: LINK_HINT,
link: (i) => links[i] ?? null,
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
};
};