Files
Aislo/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts
T
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:18:57 +09:00

306 lines
10 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;
}
export interface PreparationTable {
columns: string[];
rows: PreparationRow[];
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.pending_count}개`;
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)})`));
}
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;
}