Files
Aislo/A00_Common/calc_sheet.ts
T
2026-11-01 16:39:11 +09:00

280 lines
12 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.
/* =============================================================================
* calc_sheet.ts
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5 · 6) — 704-2 M02 계산서 보기를 올린 것.
* 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거
* 꼴 둘 —
* 비목(기본 · 704-2) = 비목 묶음마다 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
* 묶음(`bundle`) = 인력 · 재료 · 경비(장비) · 하위 — 줄의 `묶음`(714-1 서버 덧칸) 으로 다시 묶음 ·
* 장비 · 하위 로직 = 한 줄(노 · 재 · 경 칸 · 합) + 누르면 펼침(그 줄의 노 · 재 · 경 식) ·
* 묶음 머리 = 이름 · 기준단위 · 환산 근거 · 그 묶음이 쓰는 입력 칸(`쓰는입력`) · 맨 아래 비목 소계 셋 + 계
* 값 = 서버 셈 그대로(묶음 소계만 서버 줄 금액의 합) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 단추 · 표 · 페이지 CSS 없음
* ========================================================================== */
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import "./calc_sheet.css";
export interface SheetLine {
이름: string;
/** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */
글: string;
금액: number | null;
/** 714-1 서버 덧칸 — 그 줄 값이 쓰는 로직 입력 · 묶음 · 장비 · 찾는 표 기준 · 환산 근거 */
쓰는입력?: string[];
묶음?: string | null;
장비?: boolean | null;
기준단위?: string | null;
환산근거?: string;
/** 714-1b 장비 줄 펼침 — 부른 기계경비 로직의 시간당 줄(조종원 · 주연료 · 잡재료비 · 기계손료) */
펼침?: SheetUnfold | null;
}
export interface SheetUnfold {
키: string;
이름: string;
단위: string;
줄: { 비목: string; 이름: string; 글: string; 금액: number | null }[];
계: number | null;
}
export interface SheetGroup {
비목: string;
소계: number | null;
끝수?: string;
줄: SheetLine[];
}
export interface SheetInput {
이름: string;
단위?: string;
고르기?: (string | number)[];
}
export interface SheetOptions {
묶음: SheetGroup[];
계: number | null;
/** 맨 아래 계 줄 이름(「계 (원/㎡)」) */
sumLabel: string;
/** 인력 · 재료 · 경비(장비) 묶음으로 다시 묶음(없으면 서버 비목 묶음 그대로 · 704-2) */
bundle?: boolean;
/** 변수 칸 — 입력 꼴 · 지금 값 · 바꾼 뒤 할 일(없으면 변수 칸 없음) · 칸 = 묶음 줄들의 `쓰는입력` */
inputs?: SheetInput[];
values?: Record<string, string>;
onInput?: (name: string, value: string) => void;
}
/* ── 순수 셈 ── */
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
export const sheetFormula = (text: string): string =>
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
/** 장비 · 하위 로직 한 줄 — 비목마다 몫 줄(서버 줄 그대로) · 합 */
export interface SheetMerged {
이름: string;
몫: { 비목: string; 줄: SheetLine }[];
금액: number | null;
}
export interface SheetBundle {
묶음: string;
줄: (SheetLine | SheetMerged)[];
소계: number;
쓰는입력: string[];
기준단위: string[];
환산근거: string[];
}
const ORDER = ["인력", "재료", "경비", "하위"];
const OF_COST: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
const add = (a: number | null, b: number | null): number | null =>
a === null && b === null ? null : (a ?? 0) + (b ?? 0);
const merges = (l: SheetLine): boolean => !!l.장비 || l.묶음 === "하위";
/** 비목 묶음 → 인력 · 재료 · 경비 · 하위 묶음 — 장비 · 하위 줄은 비목마다 갈린 몫을 한 줄로
* (같은 이름 k 번째끼리 짝 — 서버 줄 차례 = 호표 차례 · 비목마다 같은 차례) */
export function bundleLines(groups: SheetGroup[]): SheetBundle[] {
const bundles = new Map<string, SheetBundle>();
const merged = new Map<string, SheetMerged>();
const take = (name: string): SheetBundle => {
let b = bundles.get(name);
if (!b) {
b = { 묶음: name, 줄: [], 소계: 0, 쓰는입력: [], 기준단위: [], 환산근거: [] };
bundles.set(name, b);
}
return b;
};
for (const g of groups) {
const seen = new Map<string, number>();
for (const line of g.줄) {
const b = take(line.묶음 || OF_COST[g.비목] || g.비목);
b.소계 += line.금액 ?? 0;
for (const n of line.쓰는입력 ?? []) if (!b.쓰는입력.includes(n)) b.쓰는입력.push(n);
if (line.기준단위 && !b.기준단위.includes(line.기준단위)) b.기준단위.push(line.기준단위);
if (line.환산근거 && !b.환산근거.includes(line.환산근거)) b.환산근거.push(line.환산근거);
if (!merges(line)) {
b.줄.push(line);
continue;
}
const k = seen.get(line.이름) ?? 0;
seen.set(line.이름, k + 1);
const id = `${b.묶음}\n${line.이름}\n${k}`;
let m = merged.get(id);
if (!m) {
m = { 이름: line.이름, 몫: [], 금액: null };
merged.set(id, m);
b.줄.push(m);
}
m.몫.push({ 비목: g.비목, 줄: line });
m.금액 = add(m.금액, line.금액);
}
}
const rank = (x: string): number => (ORDER.includes(x) ? ORDER.indexOf(x) : ORDER.length);
return [...bundles.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음));
}
/* ── 순수 셈 끝 ── */
const TEXT = {
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
경비: ["경비(장비)", "Expense (equipment)"],
하위: ["하위 로직", "Sub logic"],
Basis: ["기준", "Basis"],
Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"],
Fold: ["누르면 펼침", "Click to expand"],
Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b);
const won = (v: number | null, digits = 0): string =>
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
const lineRow = (line: SheetLine, label = line.이름): HTMLElement =>
createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1));
/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */
function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
let control: HTMLElement;
if (spec.고르기?.length) {
control = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value,
compact: true,
ariaLabel: spec.이름,
onChange: onInput,
}).root;
} else {
const box = createInputField({ value, compact: true });
box.input.setAttribute("aria-label", spec.이름);
box.input.addEventListener("change", () => {
if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim());
});
control = box.root;
}
const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control);
row.dataset.sheetVar = spec.이름;
return row;
}
/** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */
function mergedRows(m: SheetMerged): HTMLElement[] {
const share = (cost: string): number | null =>
m.몫.filter((x) => x.비목 === cost).reduce<number | null>((s, x) => add(s, x.줄.금액), null);
const short = t("Short")
.replace("{a}", won(share("노무비"), 1))
.replace("{b}", won(share("재료비"), 1))
.replace("{c}", won(share("경비"), 1));
const head = createCalcFormulaRow(`▸ ${m.이름}`, createCalcFormula(short), out(m.금액, false, 1));
// 펼침 = 부른 기계경비 로직 시간당 줄(손료 · 주연료 · 잡품 · 운전사 · 714-1b) → 이 줄 노 · 재 · 경 몫 식
const unfold = m.몫.find((x) => x.줄.펼침)?.줄.펼침;
const parts = [
...(unfold
? [
createText(
t("Per")
.replace("{n}", unfold.이름)
.replace("{v}", won(unfold.계, 1))
.replace("{u}", unfold.단위 || "-"),
"hint",
),
...unfold.줄.map((x) => lineRow(x, `${x.이름} (${x.비목})`)),
]
: []),
...m.몫.map((x) => lineRow(x.줄, `${x.비목}`)),
];
for (const p of parts) {
p.hidden = true;
p.dataset.sheetPart = m.이름;
}
head.dataset.sheetEquip = m.이름;
head.title = t("Fold");
head.tabIndex = 0;
head.setAttribute("role", "button");
head.setAttribute("aria-expanded", "false");
const label = head.firstElementChild as HTMLElement;
const toggle = (): void => {
const open = head.getAttribute("aria-expanded") !== "true";
head.setAttribute("aria-expanded", String(open));
label.textContent = `${open ? "▾" : "▸"} ${m.이름}`;
parts.forEach((p) => (p.hidden = !open));
};
head.addEventListener("click", toggle);
head.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
toggle();
});
return [head, ...parts];
}
/** 묶음 머리 — 이름 · 기준 · 환산 근거 · 쓰는 입력 칸 */
function headRows(
name: string,
uses: string[],
o: SheetOptions,
specs: Map<string, SheetInput>,
basis: string[] = [],
notes: string[] = [],
): HTMLElement[] {
const vars = o.onInput ? uses.filter((n) => specs.has(n)) : [];
if (!vars.length && !o.bundle) return [];
const title = [name, basis.length ? `${t("Basis")} ${basis.join(" · ")}` : ""].filter(Boolean);
const head = createText(title.join(" — "), "muted", 14);
head.dataset.sheetHead = name;
const rows: HTMLElement[] = [head];
if (notes.length) rows.push(createText(notes.join(" · "), "hint"));
const onInput = o.onInput;
if (onInput)
for (const n of vars)
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
return rows;
}
const subtotal = (g: SheetGroup): HTMLElement =>
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true));
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
const rows: HTMLElement[] = [];
const specs = new Map((o.inputs ?? []).map((s) => [s.이름, s]));
if (o.bundle) {
for (const b of bundleLines(o.묶음)) {
const name = bundleName(b.묶음);
rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거));
for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)]));
rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1)));
}
rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐
} else {
for (const g of o.묶음) {
const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))];
rows.push(...headRows(g.비목, uses, o, specs));
rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g));
}
}
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
return rows;
}