Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSummary.ts
T
eomsangdonandClaude Opus 5 46b582762c feat(b08): 구조물 집계표 칸 고치기 — 정본에 바로 쓰고 손댄 칸을 표시
- 칸 조작은 캐시(sessionStorage)에, [저장] 때 structures.json·pipe_points.json 에 바로 씀
- 산출 조건에 손댄 칸 표(고치기 전 값) · ↺ 로 되돌리면 자동으로 돌아감
- 상세 칸만 고침 — 자리·길이·높이·관경은 시·종점과 한 벌이라 구조물 놓기(B05) 몫
- 틀린 값·놓기 칸·판번호 어긋남은 아무것도 안 씀 · 값 꼴은 구조물도 제원 저장과 같음

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

399 lines
15 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_StructureSummary.ts
* **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 · 칸 고치기 (PLAN 2장).
*
* ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만.
* ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸.
* ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림.
* ⚠ 고치기는 데이터 3층 — 칸 조작은 캐시(sessionStorage)에만, [저장] 때 서버가 **정본에** 씀.
* 상세 칸만 고침 — 자리·길이·높이·관경 같은 놓기 칸은 시·종점과 한 벌이라 B05 몫(회색).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
type Source = "auto" | "user" | "library" | "empty";
type CellValue = number | string | null;
interface SummaryCell {
value: CellValue;
source: Source;
was?: CellValue;
replaced_user_value?: CellValue;
}
interface SummaryRow {
id: string;
origin: "structures" | "pipe_points";
chainage_m: number | null;
start_m: number | null;
end_m: number | null;
count: number;
length_m: number | null;
length_basis: string;
memo: string;
note?: string;
replaced?: string[];
cells: Record<string, SummaryCell>;
}
interface SummaryColumn {
key: string;
label: string;
unit: string;
input: string;
choices: string[];
editable: boolean;
}
interface SummaryTable {
type_id: string;
name: string;
columns: SummaryColumn[];
rows: SummaryRow[];
count: number;
length_total_m: number | null;
length_missing: number;
averages: Record<string, number>;
}
interface SummaryResponse {
revision: number;
tables: SummaryTable[];
notes: string[];
message?: string;
}
interface Edit {
id: string;
key: string;
value: CellValue;
}
const SOURCE_LABELS: Record<Source, string> = {
auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값",
user: "사용자 — 이 표에서 고친 값(정본에 적힘)",
library: "라이브러리 — 정본이 비어 양식 기본값으로 섬",
empty: "빈칸 — 정본도 양식도 값 없음",
};
const STYLE_ID = "b08-structure-summary-style";
const CSS = `
.b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
.b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); }
.b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); }
.b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); }
.b08-sum__cell--empty { color: var(--color-text-muted, #999); }
.b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
.b08-sum td.is-changed { background: color-mix(in srgb, var(--color-accent, #6c8ebf) 18%, transparent); }
.b08-sum .b08-grid__table td { white-space: nowrap; }
.b08-sum td input, .b08-sum td select { font-size: 12px; max-width: 8rem; }
.b08-sum td input[type=number] { width: 5rem; }
.b08-sum td button { font-size: 11px; padding: 0 4px; margin-left: 2px; cursor: pointer; }
.b08-sum__actions { display: flex; gap: 8px; align-items: center; }
`;
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
/** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */
function cellText(value: CellValue | undefined): string {
if (value === null || value === undefined || value === "") return "";
return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value);
}
function station(row: SummaryRow): string {
if (row.start_m !== null && row.end_m !== null) {
return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`;
}
return row.chainage_m === null ? "" : stationLabel(row.chainage_m);
}
/** 정본에 지금 적힌 값 — 라이브러리·빈칸은 정본이 빈 것. */
function storedValue(cell: SummaryCell | undefined): CellValue {
return cell && (cell.source === "auto" || cell.source === "user") ? cell.value : null;
}
function same(a: CellValue, b: CellValue): boolean {
return String(a ?? "") === String(b ?? "");
}
/** 고친 칸 캐시 — [저장] 전까지 이 탭(sessionStorage)에만 둠. 판번호가 다르면 버림. */
class Draft {
private readonly storageKey: string;
readonly edits = new Map<string, Edit>();
onChange: () => void = () => undefined;
constructor(
projectId: string,
readonly revision: number,
) {
this.storageKey = `b08-structure-summary-draft:${projectId}`;
try {
const saved = JSON.parse(sessionStorage.getItem(this.storageKey) ?? "null") as {
revision: number;
edits: Edit[];
} | null;
if (saved?.revision === revision) {
for (const edit of saved.edits) this.edits.set(`${edit.id}|${edit.key}`, edit);
}
} catch {
// 캐시를 못 읽으면 빈 초안 — 정본은 그대로.
}
}
get(id: string, key: string): Edit | undefined {
return this.edits.get(`${id}|${key}`);
}
set(edit: Edit, stored: CellValue): void {
const slot = `${edit.id}|${edit.key}`;
if (same(edit.value, stored)) this.edits.delete(slot);
else this.edits.set(slot, edit);
this.persist();
}
clear(): void {
this.edits.clear();
this.persist();
}
private persist(): void {
try {
if (this.edits.size) {
const body = { revision: this.revision, edits: [...this.edits.values()] };
sessionStorage.setItem(this.storageKey, JSON.stringify(body));
} else {
sessionStorage.removeItem(this.storageKey);
}
} catch {
// 저장소가 막혀도 화면 초안은 살아 있음.
}
this.onChange();
}
}
function editor(
column: SummaryColumn,
row: SummaryRow,
cell: SummaryCell | undefined,
draft: Draft,
td: HTMLTableCellElement,
): HTMLElement[] {
const stored = storedValue(cell);
const pending = draft.get(row.id, column.key);
const current = pending ? pending.value : stored;
let control: HTMLInputElement | HTMLSelectElement;
if (column.input === "select" && column.choices.length) {
const select = document.createElement("select");
select.append(new Option("—", ""));
for (const choice of column.choices) select.append(new Option(choice, choice));
select.value = current === null ? "" : String(current);
control = select;
} else {
const input = document.createElement("input");
input.type = column.input === "number" ? "number" : "text";
if (column.input === "number") {
input.min = "0";
input.step = "any";
}
input.value = current === null ? "" : String(current);
control = input;
}
if (cell?.source === "library") control.title = `비우면 양식 기본값 ${cellText(cell.value)}`;
if (control instanceof HTMLInputElement && cell?.source === "library") {
control.placeholder = cellText(cell.value);
}
const mark = (): void => {
td.classList.toggle("is-changed", Boolean(draft.get(row.id, column.key)));
};
control.addEventListener("change", () => {
const raw = control.value.trim();
const value = raw === "" ? null : column.input === "number" ? Number(raw) : raw;
draft.set({ id: row.id, key: column.key, value }, stored);
mark();
});
mark();
const parts: HTMLElement[] = [control];
if (cell?.source === "user" && cell.was !== undefined) {
const undo = el("button", "", "↺");
undo.type = "button";
undo.title = `고치기 전 값(${cellText(cell.was) || "빈칸"})으로 — [저장]하면 「자동」으로 돌아감`;
undo.addEventListener("click", () => {
control.value = cell.was === null || cell.was === undefined ? "" : String(cell.was);
control.dispatchEvent(new Event("change"));
});
parts.push(undo);
}
return parts;
}
function renderTable(table: SummaryTable, draft: Draft): HTMLElement {
const wrap = el("div", "b08-grid");
const length =
table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`;
const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : "";
wrap.append(el("p", "b08-sheet__head", `${table.name}${table.count}개소${length}${missing}`));
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table");
const head = document.createElement("tr");
head.append(el("th", "", "측점"), el("th", "", "연장(m)"));
for (const column of table.columns) {
const th = el("th", "", column.label);
if (!column.editable) th.title = "놓기 칸 — 시·종점과 한 벌이라 구조물 놓기(B05)에서 고침";
head.append(th);
}
head.append(el("th", "", "비고"));
const thead = document.createElement("thead");
thead.append(head);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2));
lengthCell.title = row.length_basis || "연장 없음";
tr.append(el("td", "", station(row)), lengthCell);
for (const column of table.columns) {
const cell = row.cells[column.key];
const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`);
if (column.editable) td.append(...editor(column, row, cell, draft, td));
else td.textContent = cellText(cell?.value);
td.title = cell ? SOURCE_LABELS[cell.source] : "";
if (cell?.source === "user" && cell.was !== undefined) {
td.title += ` · 고치기 전 ${cellText(cell.was) || "빈칸"}`;
}
if (cell?.replaced_user_value !== undefined) {
td.classList.add("b08-sum__cell--replaced");
td.title = `이 표에서 ${cellText(cell.replaced_user_value) || "빈칸"}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`;
}
tr.append(td);
}
const notes = [
row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "",
row.note ?? "",
row.memo,
].filter(Boolean);
tr.append(el("td", "", notes.join(" · ")));
tbody.append(tr);
}
const average = document.createElement("tr");
average.append(el("td", "", "평균치수"), el("td", "", ""));
for (const column of table.columns) {
const value = table.averages[column.key];
average.append(el("td", "", value === undefined ? "" : num(value, 2)));
}
average.append(el("td", "", ""));
tbody.append(average);
grid.append(thead, tbody);
scroller.append(grid);
wrap.append(scroller);
return wrap;
}
/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. [저장] 뒤엔 다시 받음. */
export function renderStructureSummary(projectId: string | null): HTMLElement {
injectStyles();
const root = el("div", "b08-sum");
if (!projectId) {
root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return root;
}
const url = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`;
let draft: Draft | null = null;
// 저장 안 한 칸이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장).
window.addEventListener("beforeunload", (event) => {
if (!draft?.edits.size || !root.isConnected) return;
event.preventDefault();
event.returnValue = "저장 안 한 칸이 있음";
});
const load = async (): Promise<void> => {
root.replaceChildren(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…"));
try {
const response = await fetch(url, { credentials: "include" });
const payload = (await response.json().catch(() => ({}))) as SummaryResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const active = new Draft(projectId, payload.revision);
draft = active;
const legend = el("div", "b08-sum__legend");
for (const source of ["auto", "user", "library", "empty"] as Source[]) {
legend.append(el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `));
}
const save = el("button", "b08-spec__save", "저장");
save.type = "button";
const discard = el("button", "b08-quantity__tab", "고친 것 버리기");
discard.type = "button";
const status = el("span", "b08-grid__caption");
active.onChange = () => {
const count = active.edits.size;
save.disabled = count === 0;
discard.disabled = count === 0;
status.textContent = count
? `고친 칸 ${count} — [저장]해야 정본에 적힘(구조물도·원단위가 그 값을 씀)`
: "상세 칸만 고침 · 자리·길이·높이·관경은 구조물 놓기(B05)에서";
};
active.onChange();
save.addEventListener("click", () => {
void (async () => {
save.disabled = true;
status.textContent = "저장 중…";
try {
const result = await fetch(url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_revision: active.revision,
edits: [...active.edits.values()],
}),
});
const body = (await result.json().catch(() => ({}))) as { message?: string };
if (!result.ok) throw new Error(body.message ?? `HTTP ${result.status}`);
active.clear();
await load();
} catch (error) {
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
save.disabled = false;
}
})();
});
discard.addEventListener("click", () => {
active.clear();
void load();
});
const actions = el("div", "b08-sum__actions");
actions.append(save, discard, status);
const total = payload.tables.reduce((sum, table) => sum + table.count, 0);
root.replaceChildren(
el(
"p",
"b08-sheet__head",
`구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`,
),
legend,
actions,
...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)),
...(payload.tables.length
? payload.tables.map((table) => renderTable(table, active))
: [el("p", "b08-grid__caption", "놓인 구조물이 없음")]),
);
} catch (error) {
root.replaceChildren(
el(
"p",
"b08-grid__caption b08-grid__caption--warn",
`구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
}
};
void load();
return root;
}