- 저장 칸 structure_formula_overrides 의 키를 장 이름에서 양식 type_id 로 바꿈(브레인 판정, PLAN 10장) - 구조물도·build_table 모두 type_id 로 찾음 · 같은 양식의 장이 모두 같은 고친 식을 받음 - 화면 안내에 「같은 양식의 장 모두에 걸림」 · 시험: 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식 남음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
588 lines
23 KiB
TypeScript
588 lines
23 KiB
TypeScript
/* =============================================================================
|
|
* B08_Quantity_UI_StructureSheet.ts
|
|
* 구조물도 탭 — 제원 조합 하나 = 한 장. 장마다 하위 탭 · 원단위 수량표 · 제원 칸 (PLAN 3장).
|
|
*
|
|
* ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이).
|
|
* 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }`
|
|
* ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음.
|
|
* ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만.
|
|
* ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로.
|
|
* ⚠ 식 고치기(PLAN 3장 ⑤) — 칸에서 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂(왕복 없음).
|
|
* [식 저장] 때는 식만 보내고 **값은 서버가 Node 로 다시 냄**(판정 Ⓐ). 저장은 양식 + 프로젝트 단위.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
|
import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula";
|
|
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
|
|
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
|
import {
|
|
buildStandardSpecPanel,
|
|
type StandardSheetSpec,
|
|
type StandardSpecResult,
|
|
} from "./B08_Quantity_UI_StructureSheet_Spec";
|
|
|
|
export interface StructureSheetRow {
|
|
no: number;
|
|
name: string;
|
|
spec: string;
|
|
basis: string;
|
|
/** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */
|
|
unit_amount: number | null;
|
|
amount: number | null;
|
|
unit: string;
|
|
basis_kind: string;
|
|
source: string;
|
|
/** 양식 줄만 — 기계가 푸는 식(명세 13장). 비면 고정형(지금 전개). */
|
|
formula?: string;
|
|
/** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */
|
|
default_formula?: string;
|
|
destination?: string;
|
|
rounding?: { mode: string; digits: number } | null;
|
|
/** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */
|
|
skipped?: boolean;
|
|
reason?: string;
|
|
error?: string;
|
|
}
|
|
|
|
const DESTINATION_LABELS: Record<string, string> = {
|
|
earthwork: "토공집계",
|
|
material: "자재총괄",
|
|
unit_price: "일위대가",
|
|
reference: "보여주기",
|
|
haul_deduction: "운반 공제",
|
|
};
|
|
|
|
const ROUNDING_LABELS: Record<string, string> = {
|
|
floor: "내림(INT)",
|
|
trunc: "버림",
|
|
round: "반올림",
|
|
ceil_away: "올림",
|
|
ceil: "위로",
|
|
round_half_even: "짝수 반올림",
|
|
};
|
|
|
|
/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */
|
|
function amountText(row: StructureSheetRow): string {
|
|
if (row.skipped) return "안 섬";
|
|
return num(row.unit_amount, 3);
|
|
}
|
|
|
|
/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */
|
|
function noteText(row: StructureSheetRow): string {
|
|
if (row.skipped) return row.reason ?? "";
|
|
if (row.error) return `⚠ ${row.error}`;
|
|
// 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③).
|
|
const origin =
|
|
row.source === "user"
|
|
? "사용자 식"
|
|
: row.source === "library"
|
|
? "양식"
|
|
: row.basis_kind === "observed"
|
|
? "실무 관측"
|
|
: "치수 전개";
|
|
const mode = row.rounding?.mode;
|
|
return mode && mode !== "none"
|
|
? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리`
|
|
: origin;
|
|
}
|
|
|
|
export interface StructureSheet extends StandardSheetSpec {
|
|
height_m: number;
|
|
unit_label: string;
|
|
billing_unit: string;
|
|
billing_total: number;
|
|
rows: StructureSheetRow[];
|
|
members: {
|
|
structure_id: string | null;
|
|
name: string;
|
|
start_m: number | null;
|
|
end_m: number | null;
|
|
length_m: number;
|
|
billing_quantity: number;
|
|
}[];
|
|
notes: string[];
|
|
unpriced_rows: string[];
|
|
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
|
|
library_item?: { type_id: string; name: string };
|
|
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
|
formula_sheet?: FormulaSheet;
|
|
}
|
|
|
|
export interface StructureSheetsResponse {
|
|
sheets: StructureSheet[];
|
|
sheet_count: number;
|
|
structure_count: number;
|
|
skipped_structures: string[];
|
|
pending_choices: { label: string; effect?: string }[];
|
|
}
|
|
|
|
const STYLE_ID = "b08-structure-sheet-style";
|
|
const CSS = `
|
|
.b08-sheet { display: flex; gap: 12px; align-items: flex-start; min-height: 0; flex: 1 1 auto; }
|
|
.b08-sheet__main { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-width: 0; min-height: 0; }
|
|
.b08-sheet__aside { flex: 0 0 17rem; max-height: 100%; overflow: auto; }
|
|
.b08-sheet__head { display: flex; justify-content: space-between; gap: 8px; margin: 0; font-size: 13px; color: var(--color-text); }
|
|
.b08-sheet__tabs { flex-wrap: wrap; }
|
|
.b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; }
|
|
/* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */
|
|
.b08-sheet__rows td:nth-child(3) { white-space: pre-line; min-width: 16rem; }
|
|
.b08-sheet__rows td:nth-child(7) { white-space: normal; max-width: 18rem; }
|
|
/* 식 칸 — 고칠 수 있는 칸은 입력으로, 고친 칸은 표시가 남음. */
|
|
.b08-sheet__formula { display: flex; gap: 4px; margin-top: 2px; }
|
|
.b08-sheet__formula input { flex: 1 1 auto; min-width: 12rem; font: 12px var(--font-mono, monospace);
|
|
padding: 1px 4px; color: var(--color-text); background: var(--color-surface);
|
|
border: 1px solid var(--color-border); }
|
|
.b08-sheet__formula input.is-changed { border-color: var(--color-accent, #6c8ebf); }
|
|
.b08-sheet__formula button { font-size: 11px; padding: 0 6px; cursor: pointer; }
|
|
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
|
|
@media (max-width: 900px) {
|
|
.b08-sheet { flex-direction: column; }
|
|
.b08-sheet__aside { flex-basis: auto; width: 100%; }
|
|
}
|
|
`;
|
|
|
|
function injectStyles(): void {
|
|
injectEarthworkGridStyles();
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = CSS;
|
|
document.head.append(style);
|
|
}
|
|
|
|
async function fetchStructureSheets(projectId: string): Promise<StructureSheetsResponse> {
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets`,
|
|
{ credentials: "include" },
|
|
);
|
|
if (!response.ok) throw new Error(`structure sheets failed: ${response.status}`);
|
|
return (await response.json()) as StructureSheetsResponse;
|
|
}
|
|
|
|
async function putStructureSheetSpec(
|
|
projectId: string,
|
|
body: StandardSpecResult & { base_revision: number },
|
|
): Promise<{ changed: number; notes: string[] }> {
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/spec`,
|
|
{
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
const payload = (await response.json().catch(() => ({}))) as {
|
|
changed?: number;
|
|
notes?: string[];
|
|
message?: string;
|
|
};
|
|
// 실패 사유(판번호 충돌 등)는 폼 안내 칸에 그대로 뜬다 — 조용히 끝나면 저장된 줄 앎.
|
|
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
|
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
|
|
}
|
|
|
|
/** 고친 식 저장 — 식만 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */
|
|
async function putStructureSheetFormulas(
|
|
projectId: string,
|
|
sheetKey: string,
|
|
rows: { seq: number; formula: string | null }[],
|
|
): Promise<{ changed: number; errors: string[] }> {
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`,
|
|
{
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sheet_key: sheetKey, rows }),
|
|
},
|
|
);
|
|
const payload = (await response.json().catch(() => ({}))) as {
|
|
changed?: number;
|
|
errors?: string[];
|
|
message?: string;
|
|
};
|
|
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
|
return { changed: payload.changed ?? 0, errors: payload.errors ?? [] };
|
|
}
|
|
|
|
/**
|
|
* 양식 장의 원단위 수량표 — 식 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂.
|
|
* `onSave` 는 고친 식만 받음 — 값은 서버가 다시 냄. 되돌린 줄은 빈 식(= 양식 식)으로 감.
|
|
*/
|
|
function formulaTable(
|
|
sheet: StructureSheet,
|
|
onSave: (rows: { seq: number; formula: string | null }[]) => Promise<string[]>,
|
|
onDirty: (dirty: boolean) => void,
|
|
): HTMLElement {
|
|
const body = sheet.formula_sheet as FormulaSheet;
|
|
const saved = new Map(sheet.rows.map((row) => [row.no, row.formula ?? ""]));
|
|
const defaults = new Map(sheet.rows.map((row) => [row.no, row.default_formula ?? ""]));
|
|
const edits = new Map<number, string>();
|
|
const cells = new Map<
|
|
number,
|
|
{ amount: HTMLElement; note: HTMLElement; input: HTMLInputElement }
|
|
>();
|
|
|
|
const wrap = el("div", "b08-grid");
|
|
const scroller = el("div", "b08-grid__scroll");
|
|
const grid = el("table", "b08-grid__table b08-grid__table--summary b08-sheet__rows");
|
|
const headRow = document.createElement("tr");
|
|
for (const label of ["공종", "규격", "산출 근거 · 식", "수량", "단위", "갈 곳", "비고"]) {
|
|
headRow.append(el("th", "", label));
|
|
}
|
|
const thead = document.createElement("thead");
|
|
thead.append(headRow);
|
|
const tbody = document.createElement("tbody");
|
|
|
|
const status = el("span", "b08-grid__caption");
|
|
const saveButton = el("button", "b08-spec__save", "식 저장");
|
|
saveButton.type = "button";
|
|
const discardButton = el("button", "b08-quantity__tab", "고친 것 버리기");
|
|
discardButton.type = "button";
|
|
|
|
// 고친 식으로 장을 다시 풀어 **모든 줄**을 고침 — 앞 줄을 고치면 뒷줄도 따라 바뀜.
|
|
const recompute = (): void => {
|
|
const results = evaluateSheet({
|
|
...body,
|
|
rows: body.rows.map((row) =>
|
|
edits.has(row.seq) ? { ...row, formula: edits.get(row.seq), source: "user" } : row,
|
|
),
|
|
});
|
|
for (const result of results) {
|
|
const cell = cells.get(result.seq);
|
|
const row = sheet.rows.find((item) => item.no === result.seq);
|
|
if (!cell || !row) continue;
|
|
const formula = cell.input.value.trim();
|
|
const local: StructureSheetRow = {
|
|
...row,
|
|
unit_amount: result.amount === null ? null : Number(result.amount),
|
|
skipped: result.skipped,
|
|
reason: result.reason ?? "",
|
|
error: result.error ?? "",
|
|
source: formula && formula !== defaults.get(result.seq) ? "user" : "library",
|
|
};
|
|
cell.amount.textContent = amountText(local);
|
|
cell.note.textContent = noteText(local);
|
|
cell.input.classList.toggle("is-changed", formula !== saved.get(result.seq));
|
|
}
|
|
const dirty = [...cells.entries()].some(
|
|
([seq, cell]) => cell.input.value.trim() !== saved.get(seq),
|
|
);
|
|
saveButton.disabled = !dirty;
|
|
discardButton.disabled = !dirty;
|
|
status.textContent = dirty
|
|
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
|
|
: "";
|
|
onDirty(dirty);
|
|
};
|
|
|
|
for (const row of sheet.rows) {
|
|
const tr = document.createElement("tr");
|
|
const basis = el("td", "", row.basis.replace(/\*\*/g, ""));
|
|
const line = el("div", "b08-sheet__formula");
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.value = row.formula ?? "";
|
|
input.title = `양식 식: ${row.default_formula ?? ""}`;
|
|
input.addEventListener("input", () => {
|
|
const value = input.value.trim();
|
|
if (value === saved.get(row.no)) edits.delete(row.no);
|
|
else edits.set(row.no, value || (defaults.get(row.no) ?? ""));
|
|
recompute();
|
|
});
|
|
const revert = el("button", "", "양식 식으로");
|
|
revert.type = "button";
|
|
revert.title = "이 줄을 양식 원래 식으로 되돌림";
|
|
revert.addEventListener("click", () => {
|
|
input.value = defaults.get(row.no) ?? "";
|
|
input.dispatchEvent(new Event("input"));
|
|
});
|
|
line.append(input, revert);
|
|
basis.append(line);
|
|
const amount = el("td", "", amountText(row));
|
|
const note = el("td", "", noteText(row));
|
|
tr.append(
|
|
el("td", "", row.name),
|
|
el("td", "", row.spec),
|
|
basis,
|
|
amount,
|
|
el("td", "", row.unit),
|
|
el("td", "", DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? ""),
|
|
note,
|
|
);
|
|
cells.set(row.no, { amount, note, input });
|
|
tbody.append(tr);
|
|
}
|
|
grid.append(thead, tbody);
|
|
scroller.append(grid);
|
|
|
|
saveButton.addEventListener("click", () => {
|
|
void (async () => {
|
|
saveButton.disabled = true;
|
|
status.textContent = "저장 중…";
|
|
const rows = [...cells.entries()]
|
|
.filter(([seq, cell]) => cell.input.value.trim() !== saved.get(seq))
|
|
.map(([seq, cell]) => {
|
|
const value = cell.input.value.trim();
|
|
// 양식 식과 같거나 비면 「고친 적 없음」으로 되돌림.
|
|
return { seq, formula: value && value !== defaults.get(seq) ? value : null };
|
|
});
|
|
try {
|
|
const errors = await onSave(rows);
|
|
status.textContent = errors.length ? `⚠ 저장했으나 안 서는 줄: ${errors.join(" · ")}` : "";
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "식을 저장하지 못함";
|
|
saveButton.disabled = false;
|
|
}
|
|
})();
|
|
});
|
|
discardButton.addEventListener("click", () => {
|
|
edits.clear();
|
|
for (const [seq, cell] of cells) cell.input.value = saved.get(seq) ?? "";
|
|
recompute();
|
|
});
|
|
|
|
const actions = el("div", "b08-sheet__actions");
|
|
actions.append(saveButton, discardButton, status);
|
|
saveButton.disabled = true;
|
|
discardButton.disabled = true;
|
|
wrap.append(scroller, actions);
|
|
return wrap;
|
|
}
|
|
|
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
className: string,
|
|
text = "",
|
|
): HTMLElementTagNameMap[K] {
|
|
const node = document.createElement(tag);
|
|
node.className = className;
|
|
node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function num(value: number | null | undefined, digits: number): string {
|
|
if (value === null || value === undefined || Number.isNaN(value)) return "-";
|
|
return value.toLocaleString("ko-KR", {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits,
|
|
});
|
|
}
|
|
|
|
function warn(title: string, items: string[]): HTMLElement | null {
|
|
if (!items.length) return null;
|
|
return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`);
|
|
}
|
|
|
|
function table(head: string[], rows: string[][], extraClass = ""): HTMLElement {
|
|
const scroller = el("div", "b08-grid__scroll");
|
|
const grid = el("table", `b08-grid__table b08-grid__table--summary ${extraClass}`.trim());
|
|
const thead = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
for (const label of head) headRow.append(el("th", "", label));
|
|
thead.append(headRow);
|
|
const tbody = document.createElement("tbody");
|
|
for (const cells of rows) {
|
|
const tr = document.createElement("tr");
|
|
for (const text of cells) tr.append(el("td", "", text));
|
|
tbody.append(tr);
|
|
}
|
|
grid.append(thead, tbody);
|
|
scroller.append(grid);
|
|
return scroller;
|
|
}
|
|
|
|
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */
|
|
function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement {
|
|
const main = el("div", "b08-sheet__main");
|
|
const head = el("p", "b08-sheet__head");
|
|
const total = sheet.billing_total
|
|
? ` · 합 ${num(sheet.billing_total, 2)}${sheet.billing_unit}`
|
|
: "";
|
|
head.append(
|
|
el(
|
|
"span",
|
|
"",
|
|
`${sheet.title} — ${sheet.member_count}개소${total} · ` +
|
|
// 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음.
|
|
(sheet.library_item ? `양식 「${sheet.library_item.name}」` : "양식 없음(지금 전개)"),
|
|
),
|
|
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
|
|
el("span", "b08-grid__caption", sheet.unit_label),
|
|
);
|
|
main.append(head);
|
|
if (!sheet.rows.length) {
|
|
main.append(
|
|
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
|
|
);
|
|
} else if (editor) {
|
|
main.append(editor);
|
|
} else {
|
|
main.append(
|
|
table(
|
|
["공종", "규격", "산출 근거", "수량", "단위", "갈 곳", "비고"],
|
|
sheet.rows.map((row) => [
|
|
row.name,
|
|
row.spec,
|
|
// 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임.
|
|
// 양식 줄은 **식을 버리지 않고** 설명 밑에 함께 보임(명세 13장 지킬 것 ④).
|
|
row.basis.replace(/\*\*/g, "") + (row.formula ? `\n= ${row.formula}` : ""),
|
|
amountText(row),
|
|
row.unit,
|
|
DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? "",
|
|
noteText(row),
|
|
]),
|
|
"b08-sheet__rows",
|
|
),
|
|
);
|
|
}
|
|
for (const notice of [
|
|
warn("단위당을 못 낸 줄", sheet.unpriced_rows),
|
|
warn(
|
|
"사유",
|
|
sheet.notes.map((note) => note.replace(/\*\*/g, "")),
|
|
),
|
|
]) {
|
|
if (notice) main.append(notice);
|
|
}
|
|
main.append(
|
|
el("p", "b08-grid__caption", "이 장에 묶인 개소"),
|
|
table(
|
|
["구조물", "구간", "연장(m)", `수량(${sheet.billing_unit})`],
|
|
sheet.members.map((member) => {
|
|
const { start_m: start, end_m: end } = member;
|
|
const span =
|
|
typeof start === "number" && typeof end === "number"
|
|
? start === end
|
|
? stationLabel(start)
|
|
: `${stationLabel(start)} ~ ${stationLabel(end)}`
|
|
: "";
|
|
return [member.name, span, num(member.length_m, 1), num(member.billing_quantity, 2)];
|
|
}),
|
|
),
|
|
);
|
|
return main;
|
|
}
|
|
|
|
/** 구조물도 탭 본문. 받아 오는 동안 안내를 띄우고, 제원을 저장하면 **정본에서 다시 받아** 그림. */
|
|
export function renderStructureSheets(projectId: string | null): HTMLElement {
|
|
injectStyles();
|
|
const wrap = el("div", "b08-grid");
|
|
if (!projectId) {
|
|
wrap.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
|
|
return wrap;
|
|
}
|
|
// 저장 안 한 식이 조용히 사라지지 않게 — 나갈 때 묻기(자동저장은 안 만듦, CLAUDE.md 5장).
|
|
let dirty = false;
|
|
window.addEventListener("beforeunload", (event) => {
|
|
if (!dirty || !wrap.isConnected) return;
|
|
event.preventDefault();
|
|
event.returnValue = "저장 안 한 식이 있음";
|
|
});
|
|
|
|
const paint = (response: StructureSheetsResponse, memberId: string | null, notes: string[]) => {
|
|
const sheets = response.sheets ?? [];
|
|
const caption = el(
|
|
"p",
|
|
"b08-grid__caption",
|
|
`구조물도 ${sheets.length}장 · 구조물 ${response.structure_count}개 · 제원 조합 하나가 한 장 · 할증 전 값`,
|
|
);
|
|
const nodes: HTMLElement[] = [caption];
|
|
const skipped = warn("건너뛴 구조물", response.skipped_structures ?? []);
|
|
if (skipped) nodes.push(skipped);
|
|
for (const choice of response.pending_choices ?? []) {
|
|
const effect = choice.effect ? ` · ${choice.effect.replace(/\*\*/g, "")}` : "";
|
|
nodes.push(el("p", "b08-quantity__notice", `⚠ 미확정: ${choice.label}${effect}`));
|
|
}
|
|
if (!sheets.length) {
|
|
nodes.push(
|
|
el(
|
|
"p",
|
|
"b08-quantity__message",
|
|
response.structure_count
|
|
? "구조물이 모두 다른 단계에서 셈되어 구조물도에 실리지 않음"
|
|
: "배치된 구조물이 없음 — 구조물을 먼저 배치할 것",
|
|
),
|
|
);
|
|
wrap.replaceChildren(...nodes);
|
|
return;
|
|
}
|
|
|
|
// 저장 뒤에는 **같은 개소가 든 장**을 다시 연다 — 제원이 바뀌면 장 이름(key)도 바뀌기 때문.
|
|
const found = sheets.findIndex((sheet) =>
|
|
sheet.members.some((member) => memberId && member.structure_id === memberId),
|
|
);
|
|
const tabs = el("div", "b08-quantity__tabs b08-sheet__tabs");
|
|
const pane = el("div", "b08-sheet");
|
|
const buttons: HTMLButtonElement[] = [];
|
|
const show = (index: number, initialNotes: string[] = []): void => {
|
|
buttons.forEach((button, i) => button.classList.toggle("is-active", i === index));
|
|
const sheet = sheets[index];
|
|
const aside = el("div", "b08-sheet__aside");
|
|
// 판정된 기울기는 **칸에 적지 않고 도움말로만** — 적어 두면 「안 정함」이 사라진다.
|
|
const judged = /1:([\d.]+)/.exec(sheet.title)?.[1] ?? null;
|
|
aside.append(
|
|
buildStandardSpecPanel(
|
|
sheet,
|
|
judged,
|
|
async (result) => {
|
|
const { revision } = await fetchStructures(projectId);
|
|
const saved = await putStructureSheetSpec(projectId, {
|
|
...result,
|
|
base_revision: revision,
|
|
});
|
|
const after = [`${saved.changed}개소에 반영했습니다.`, ...saved.notes];
|
|
// 정본이 바뀌었으니 표를 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 됨.
|
|
await load(sheet.members[0]?.structure_id ?? null, after);
|
|
return after;
|
|
},
|
|
initialNotes,
|
|
),
|
|
);
|
|
const editor = sheet.formula_sheet
|
|
? formulaTable(
|
|
sheet,
|
|
async (rows) => {
|
|
const result = await putStructureSheetFormulas(projectId, sheet.key, rows);
|
|
// 서버가 다시 푼 값으로 **다시 받아** 그림 — 화면 계산을 정본으로 적지 않음.
|
|
await load(sheet.members[0]?.structure_id ?? null);
|
|
return result.errors;
|
|
},
|
|
(next) => {
|
|
dirty = next;
|
|
},
|
|
)
|
|
: null;
|
|
pane.replaceChildren(sheetBody(sheet, editor), aside);
|
|
};
|
|
sheets.forEach((sheet, index) => {
|
|
const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`);
|
|
button.type = "button";
|
|
button.addEventListener("click", () => {
|
|
// 다른 장으로 가면 고친 식이 사라짐 — 조용히 버리지 않고 물음.
|
|
if (dirty && !window.confirm("저장 안 한 식이 있음 — 버리고 다른 장으로 갈까요?")) return;
|
|
dirty = false;
|
|
show(index);
|
|
});
|
|
buttons.push(button);
|
|
tabs.append(button);
|
|
});
|
|
wrap.replaceChildren(...nodes, tabs, pane);
|
|
show(found >= 0 ? found : 0, notes);
|
|
};
|
|
|
|
const load = async (memberId: string | null = null, notes: string[] = []): Promise<void> => {
|
|
try {
|
|
paint(await fetchStructureSheets(projectId), memberId, notes);
|
|
} catch {
|
|
wrap.replaceChildren(el("p", "b08-quantity__message", "구조물도를 불러오지 못함"));
|
|
}
|
|
};
|
|
|
|
wrap.append(el("p", "b08-quantity__message", "구조물도를 불러오는 중…"));
|
|
void load();
|
|
return wrap;
|
|
}
|