Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts
T
eomsangdonandClaude Opus 5.5 9d5694371f feat(b09): 자재단가대비표 적용 단가 = 칸 누름 고르기 메뉴 · 고른 출처 칸 강조 · 기본 최저 (183-3)
- 공용 표 cellClass 옵션 하나(칸 class · 안 주면 같은 글)
- B09: TabTable cellClass · choices → Shell 이 넘김 · 누른 칸 아래 B09 메뉴 한 벌(openMenuAt · 우클릭 메뉴와 공유)
- 대비표: 적용 단가 칸 누름 = 값 있는 출처 · 견적 · 최저(자동) → pick(옛 수기 applied 지움) · 수기 적용 칸 · 출처 칸 돌림 없앰 · 적용 출처 단가 칸 is-picked 강조(테마 토큰)
- 셈 · 저장 그대로(pick › applied › 최저 짝)
- 시험: test_183_3_b09_applied_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 12:02:06 +09:00

363 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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)
* · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음)
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
* ========================================================================== */
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)],
];
}
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 || [];
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: files.length ? `${files.length} 파일` : "첨부",
};
for (const s of SOURCES) {
row[`p:${s}`] = r.prices[s];
row[`g:${s}`] = r.pages[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),
// 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값)
...SOURCES.flatMap((s) => [
money(`p:${s}`, "단가", [s]),
s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]),
]),
money("lowest", "최저"),
money("applied", "적용 단가", ["적용"]),
text("applied_from", "출처", 80, ["적용"]),
text("supply", "관급 · 별도", 80),
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
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") {
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,
};
};