Files
Aislo/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts
T
eomsangdonandClaude Opus 5 364c28a700 fix(b08): 준비공 상태 칸 「입력이 필요함」과 「값을 낼 근거가 없음」을 가름 · 인계 막힘 갈래도 상태를 따름(㉴)
- 입력이면 서는 줄(표토 두께·거리 · 임목폐기물 조사값 · 부대시설 개소 · 임목파쇄 부피 · 앞 단계 횡단·사면표) → 「입력이 필요함」 · 인계 input_missing
- 자료·산식이 없는 줄(뿌리 운반 부피 · 사방 원단위 · 사면표에 성토고 칸 없음) → 「근거 없음」 · 인계 unit_data_missing(종전 input_missing = 반대 방향 거짓 사유)
- 사면표가 섰는데 비탈 규준틀 0 개소 → 「해당 없음」
- 품셈 공종이 없는 부대시설은 개소를 넣어도 인계 input_missing 그대로(별도 단가)
- 표 머리 「입력이 필요한 줄 N · 근거 없는 줄 M」 · 700줄 — 규준틀 재료를 `_Preparation_FrameMaterial` 로 뗌(719 → 651)
- 936be972: 15줄 중 입력 7 · 근거 없음 1(뿌리 운반) · 내역 「원단위가 없습니다(우리가 만들 것)」 · 금액 불변 155,207,971

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 00:44:33 +09:00

320 lines
11 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.
/* =============================================================================
* B08_Quantity_UI_SummaryGrid.ts
* 토공집계표·운반거리 그리드 (PLAN 8-11·8-3).
*
* 토공집계표 열은 거창 실무 시트 그대로 — 구분·공종·규격·단위·계·비고.
* 비고에는 **설계자가 정한 값만** 남는다(반영률을 바꿨을 때·비율 합이 100 이 아닐 때).
* 기본값 그대로면 비워 둔다 — 안내가 매번 뜨면 잡음이 된다.
*
* ⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 아니다**(품셈 1-2-7). 그 줄에
* 「내역 제외」를 붙여 화면에서도 보이게 한다 — 규칙이 코드에만 있으면 잊힌다.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
attachProvenance,
markProvenanceCell,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 줄 하나의 칸에 열 키를 차례대로 심는다.
* 이 세 표는 칸을 `textCell()` 로 줄지어 붙이므로 **다 지은 뒤에 차례로 짚는 것**이
* 가장 적게 고치는 길이다. `override` 는 **그 칸만 열 등급을 이기는** 자리다 —
* 같은 열이라도 줄마다 성격이 갈리는 것(내역 제외 줄·값을 못 세운 줄)을 위한 것이다.
*/
function markRow(
tr: HTMLTableRowElement,
keys: readonly (string | null)[],
override?: Record<number, string | undefined>,
): void {
[...tr.children].forEach((cell, index) => {
const key = keys[index];
if (key) markProvenanceCell(cell as HTMLElement, key, override?.[index]);
});
}
/** 열 차례 — 비고는 사유 글이라 사전을 안 붙인다(`null`). */
const SUMMARY_KEYS = ["group", "item", "spec", "unit", "amount", null] as const;
const HAUL_KEYS = ["equipment", "ground", "volume_m3", "average_distance_m", "legs", null] as const;
const PREPARATION_KEYS = ["group", "item", "unit", "amount", "status", null] as const;
export interface SummaryRow {
group: string;
item: string;
spec: string;
unit: string;
amount: number;
note: string;
in_bill: boolean;
}
export interface SummaryTable {
columns: string[];
rows: SummaryRow[];
rock_classes: string[];
}
export interface HaulRow {
equipment: string;
ground: string;
volume_m3: number;
average_distance_m: number;
legs: number;
in_bill: boolean;
}
export interface HaulTable {
rows: HaulRow[];
legs: {
equipment: string;
ground: string;
volume_m3: number;
distance_m: number;
from_m: number;
to_m: number;
}[];
bill_row_count: number;
}
/** 운반수단 표기 — 서버 키를 실무 시트 문구로. */
const HAUL_LABELS: Record<string, string> = {
free_haul: "무대(종방향유용토)",
dozer: "도자운반",
dump_truck: "덤프운반",
};
function num(value: number | undefined, digits: number): string {
if (value === undefined || value === null || Number.isNaN(value) || value === 0) return "";
return value.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function textCell(text: string, className?: string): HTMLTableCellElement {
const td = document.createElement("td");
td.textContent = text;
if (className) td.className = className;
return td;
}
/** 토공집계표 — 실무 시트와 같은 여섯 열. */
export function renderSummaryGrid(table: SummaryTable, sheet?: ProvenanceSheet): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of table.columns) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
let lastGroup = "";
for (const row of table.rows) {
const tr = document.createElement("tr");
// 같은 구분이 이어지면 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다.
tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station"));
lastGroup = row.group;
tr.append(textCell(row.item));
tr.append(textCell(row.spec));
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.amount, row.unit === "㎥" ? 2 : 1)));
const note = textCell(row.note, "b08-grid__note");
if (!row.in_bill) {
const tag = document.createElement("span");
tag.className = "b08-grid__tag";
tag.textContent = L("B08_Quantity_Haul_Excluded");
note.prepend(tag);
}
tr.append(note);
// ⚠ 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 아닌** 줄은 「계」가
// 최종이 아니라 **제외**임(품셀 1-2-7). 못 세운 것과 뜻이 정반대라 칸 등급을 갈라 준다.
markRow(tr, SUMMARY_KEYS, row.in_bill ? undefined : { 4: "excluded" });
body.append(tr);
}
element.append(head, body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
/** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */
export function renderHaulGrid(
table: HaulTable,
available: boolean,
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
if (!available || !table.rows.length) {
const message = document.createElement("p");
message.className = "b08-quantity__message";
message.textContent = L("B08_Quantity_Haul_Missing");
wrap.append(message);
return wrap;
}
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `내역 줄 ${table.bill_row_count}개 · 근거 구간 ${table.legs.length}개 · 토량 가중평균`;
wrap.append(caption);
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of [
"운반수단",
"지반유형",
"토량(㎥)",
"평균운반거리(m)",
"근거 구간",
"비고",
]) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(HAUL_LABELS[row.equipment] ?? row.equipment, "b08-grid__station"));
tr.append(textCell(row.ground));
tr.append(textCell(num(row.volume_m3, 2)));
tr.append(textCell(num(row.average_distance_m, 2)));
tr.append(textCell(String(row.legs)));
const note = textCell("", "b08-grid__note");
if (!row.in_bill) {
const tag = document.createElement("span");
tag.className = "b08-grid__tag";
tag.textContent = L("B08_Quantity_Haul_Excluded");
note.append(tag);
// 왜 빠지는지 같이 적는다 — 「제외」만 있으면 빠뜨린 것으로 오해된다.
note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함"));
}
tr.append(note);
// 내역 줄이 안 되는 줄은 토량·거리 둘 다 「제외」임 — 값은 검산에만 쓴다.
markRow(tr, HAUL_KEYS, row.in_bill ? undefined : { 2: "excluded", 3: "excluded" });
body.append(tr);
}
element.append(head, body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
export interface PreparationRow {
group: string;
item: string;
unit: string;
amount: number | null;
status: string;
reason: string;
reference_amount?: number;
work_item_code: string | null;
/** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */
unconfirmed?: number;
}
export interface PreparationTable {
columns: string[];
rows: PreparationRow[];
/** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */
input_count?: number;
pending_count: number;
row_count: number;
}
/** 준비공·사방공 — **못 서는 줄도 보인다.**
*
* 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도
* 상태와 사유를 달아 그대로 세운다.
*/
export function renderPreparationGrid(
table: PreparationTable,
sheet?: ProvenanceSheet,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 입력이 필요한 줄 ${table.input_count ?? 0}개 · 값을 낼 근거가 없는 줄 ${table.pending_count}개`;
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) {
const badge = document.createElement("span");
badge.style.cssText =
"margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px";
badge.textContent = `미확정 ${unconfirmed}건`;
caption.append(badge);
}
wrap.append(caption);
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of table.columns) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
let lastGroup = "";
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station"));
lastGroup = row.group;
tr.append(textCell(row.item));
tr.append(textCell(row.unit, "b08-grid__unit"));
// 값이 없으면 빈칸이 아니라 「-」 — 빈칸이면 0 으로 오해된다.
tr.append(textCell(row.amount === null ? "" : num(row.amount, 2)));
tr.append(textCell(row.status));
const note = textCell(row.reason.replace(/\*\*/g, ""), "b08-grid__note");
if (row.reference_amount) {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
// 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정).
if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)";
tr.append(note);
// 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다.
markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined);
body.append(tr);
}
element.append(head, body);
attachProvenance(element, sheet);
scroller.append(element);
wrap.append(scroller);
return wrap;
}