무슨 일이 있었나 랩탑 줄의 병합 `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
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|