- 칸 조작은 캐시(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
399 lines
15 KiB
TypeScript
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;
|
|
}
|