auto: 2026-09-07 23:12 (EOMSANGDON-HOME)
This commit is contained in:
@@ -53,6 +53,14 @@ export interface SlopeTable {
|
|||||||
unclosed_stations: number[];
|
unclosed_stations: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */
|
||||||
|
export interface QuantitySettings {
|
||||||
|
rock_class_set?: string;
|
||||||
|
rock_classes?: string[];
|
||||||
|
rock_ratios_pct?: Record<string, number>;
|
||||||
|
application_ratios_pct?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EarthworkTable {
|
export interface EarthworkTable {
|
||||||
method: string;
|
method: string;
|
||||||
station_count: number;
|
station_count: number;
|
||||||
@@ -61,6 +69,12 @@ export interface EarthworkTable {
|
|||||||
totals: Record<string, number>;
|
totals: Record<string, number>;
|
||||||
conversion_factors?: Record<string, { compacted: number }>;
|
conversion_factors?: Record<string, { compacted: number }>;
|
||||||
slope?: SlopeTable;
|
slope?: SlopeTable;
|
||||||
|
/** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */
|
||||||
|
summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable;
|
||||||
|
haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable;
|
||||||
|
/** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */
|
||||||
|
haul_available?: boolean;
|
||||||
|
settings?: QuantitySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
|
/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
|
||||||
|
|||||||
@@ -121,6 +121,35 @@ const CSS = `
|
|||||||
}
|
}
|
||||||
|
|
||||||
.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; }
|
.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; }
|
||||||
|
.b08-quantity__pane { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* 집계·운반표는 열이 적어 왼쪽 정렬이 읽기 좋다 — 숫자 칸만 오른쪽으로 둔다. */
|
||||||
|
.b08-grid__table--summary th,
|
||||||
|
.b08-grid__table--summary td { text-align: left; }
|
||||||
|
.b08-grid__table--summary td:nth-child(5),
|
||||||
|
.b08-grid__table--summary td:nth-child(4) { text-align: right; }
|
||||||
|
.b08-grid__unit { text-align: center; }
|
||||||
|
.b08-grid__note { white-space: normal; max-width: 26rem; }
|
||||||
|
|
||||||
|
/* 「내역 제외」 같은 표시 — 규칙이 코드에만 있으면 잊힌다. 화면에 남긴다. */
|
||||||
|
.b08-grid__tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b08-quantity__input {
|
||||||
|
width: 5rem;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
|
.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
|
||||||
.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
|
.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
|
||||||
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../A00_Common/b_workflow_nav";
|
} from "../A00_Common/b_workflow_nav";
|
||||||
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
|
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
|
||||||
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
||||||
|
import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid";
|
||||||
|
|
||||||
/** locale 헬퍼 */
|
/** locale 헬퍼 */
|
||||||
function L(key: keyof typeof ui_locales): string {
|
function L(key: keyof typeof ui_locales): string {
|
||||||
@@ -46,6 +47,24 @@ async function fetchEarthworkTable(projectId: string): Promise<EarthworkTable> {
|
|||||||
return (await response.json()) as EarthworkTable;
|
return (await response.json()) as EarthworkTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */
|
||||||
|
async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/settings`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
rock_class_set: draft.rock_class_set ?? null,
|
||||||
|
rock_ratios_pct: draft.rock_ratios_pct,
|
||||||
|
application_ratios_pct: draft.application_ratios_pct,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`quantity settings save failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */
|
/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */
|
||||||
function field(label: string, value: string): HTMLElement {
|
function field(label: string, value: string): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
@@ -59,10 +78,43 @@ function field(label: string, value: string): HTMLElement {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */
|
/** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */
|
||||||
|
function numberField(
|
||||||
|
label: string,
|
||||||
|
value: number,
|
||||||
|
onInput: (value: number) => void,
|
||||||
|
): HTMLElement {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "b08-quantity__field";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = label;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "number";
|
||||||
|
input.className = "b08-quantity__input";
|
||||||
|
input.min = "0";
|
||||||
|
input.step = "1";
|
||||||
|
input.value = String(value);
|
||||||
|
// 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장).
|
||||||
|
input.addEventListener("input", () => onInput(Number(input.value)));
|
||||||
|
row.append(name, input);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */
|
||||||
|
interface DraftSettings {
|
||||||
|
rock_class_set?: string;
|
||||||
|
rock_ratios_pct: Record<string, number>;
|
||||||
|
application_ratios_pct: Record<string, number>;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행.
|
||||||
|
* `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */
|
||||||
function buildQuantitySidePanel(
|
function buildQuantitySidePanel(
|
||||||
projectId: string | null,
|
projectId: string | null,
|
||||||
table: EarthworkTable | null,
|
table: EarthworkTable | null,
|
||||||
|
draft: DraftSettings,
|
||||||
|
reload: () => void,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
panel.className = "b08-quantity__panel";
|
panel.className = "b08-quantity__panel";
|
||||||
@@ -77,6 +129,57 @@ function buildQuantitySidePanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
|
||||||
|
const classes = table?.summary?.rock_classes ?? [];
|
||||||
|
if (classes.length) {
|
||||||
|
panel.append(field(L("B08_Quantity_Side_RockRatios"), ""));
|
||||||
|
for (const name of classes) {
|
||||||
|
panel.append(
|
||||||
|
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
|
||||||
|
draft.rock_ratios_pct[name] = value;
|
||||||
|
draft.dirty = true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 반영률 — 기본 100 %. 실무 관측 80/50/80 은 기본값이 아니다(PLAN 8-11) ──
|
||||||
|
const ratios = table?.settings?.application_ratios_pct ?? {};
|
||||||
|
if (Object.keys(ratios).length) {
|
||||||
|
panel.append(field(L("B08_Quantity_Side_Ratios"), ""));
|
||||||
|
for (const key of Object.keys(ratios)) {
|
||||||
|
panel.append(
|
||||||
|
numberField(key, draft.application_ratios_pct[key] ?? 100, (value) => {
|
||||||
|
draft.application_ratios_pct[key] = value;
|
||||||
|
draft.dirty = true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveButton = createButton({
|
||||||
|
label: L("B08_Quantity_Btn_Save"),
|
||||||
|
variant: "outlined",
|
||||||
|
onClick: () => {
|
||||||
|
if (!projectId) {
|
||||||
|
showToast(L("B08_Quantity_Save_Failed"), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveButton.disabled = true;
|
||||||
|
saveQuantitySettings(projectId, draft)
|
||||||
|
.then(() => {
|
||||||
|
draft.dirty = false;
|
||||||
|
showToast(L("B08_Quantity_Save_Success"), "success");
|
||||||
|
// 조건이 바뀌면 집계·운반 값이 달라진다 — 표를 다시 받아 그린다.
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast(L("B08_Quantity_Save_Failed"), "error");
|
||||||
|
saveButton.disabled = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const confirmButton = createButton({
|
const confirmButton = createButton({
|
||||||
label: L("B08_Quantity_Btn_Confirm"),
|
label: L("B08_Quantity_Btn_Confirm"),
|
||||||
variant: "filled",
|
variant: "filled",
|
||||||
@@ -105,35 +208,67 @@ function buildQuantitySidePanel(
|
|||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */
|
/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */
|
||||||
function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement {
|
function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement {
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "b08-quantity__body";
|
body.className = "b08-quantity__body";
|
||||||
|
|
||||||
const tabs = document.createElement("div");
|
const tabs = document.createElement("div");
|
||||||
tabs.className = "b08-quantity__tabs";
|
tabs.className = "b08-quantity__tabs";
|
||||||
const tab = document.createElement("button");
|
const pane = document.createElement("div");
|
||||||
tab.type = "button";
|
pane.className = "b08-quantity__pane";
|
||||||
tab.className = "b08-quantity__tab is-active";
|
|
||||||
tab.textContent = L("B08_Quantity_Tab_Earthwork");
|
const message = (text: string): HTMLElement => {
|
||||||
tabs.append(tab);
|
const element = document.createElement("p");
|
||||||
body.append(tabs);
|
element.className = "b08-quantity__message";
|
||||||
|
element.textContent = text;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
const message = document.createElement("p");
|
body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
|
||||||
message.className = "b08-quantity__message";
|
|
||||||
message.textContent = L("B08_Quantity_Grid_Failed");
|
|
||||||
body.append(message);
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
if (!table || !table.rows?.length) {
|
if (!table || !table.rows?.length) {
|
||||||
const message = document.createElement("p");
|
body.append(tabs, message(L("B08_Quantity_Grid_Empty")));
|
||||||
message.className = "b08-quantity__message";
|
|
||||||
message.textContent = L("B08_Quantity_Grid_Empty");
|
|
||||||
body.append(message);
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
body.append(renderEarthworkGrid(table));
|
|
||||||
|
const sheets: { label: string; build: () => HTMLElement }[] = [
|
||||||
|
{ label: L("B08_Quantity_Tab_Earthwork"), build: () => renderEarthworkGrid(table) },
|
||||||
|
{
|
||||||
|
label: L("B08_Quantity_Tab_Summary"),
|
||||||
|
build: () =>
|
||||||
|
table.summary
|
||||||
|
? renderSummaryGrid(table.summary)
|
||||||
|
: message(L("B08_Quantity_Grid_Empty")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: L("B08_Quantity_Tab_Haul"),
|
||||||
|
build: () =>
|
||||||
|
table.haul
|
||||||
|
? renderHaulGrid(table.haul, Boolean(table.haul_available))
|
||||||
|
: message(L("B08_Quantity_Haul_Missing")),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const buttons: HTMLButtonElement[] = [];
|
||||||
|
const show = (index: number): void => {
|
||||||
|
buttons.forEach((button, i) => button.classList.toggle("is-active", i === index));
|
||||||
|
pane.replaceChildren(sheets[index].build());
|
||||||
|
};
|
||||||
|
sheets.forEach((sheet, index) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "b08-quantity__tab";
|
||||||
|
button.textContent = sheet.label;
|
||||||
|
button.addEventListener("click", () => show(index));
|
||||||
|
buttons.push(button);
|
||||||
|
tabs.append(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
body.append(tabs, pane);
|
||||||
|
show(0);
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +290,26 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장).
|
||||||
|
const stored = table?.settings ?? {};
|
||||||
|
const draft: DraftSettings = {
|
||||||
|
rock_class_set: stored.rock_class_set,
|
||||||
|
rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) },
|
||||||
|
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
|
||||||
|
dirty: false,
|
||||||
|
};
|
||||||
|
const reload = (): void => {
|
||||||
|
root.replaceChildren();
|
||||||
|
void renderB08Quantity(root);
|
||||||
|
};
|
||||||
|
// 저장 안 한 값이 조용히 사라지지 않게 나갈 때 알린다 — 이 구조의 대가다.
|
||||||
|
const warnUnsaved = (event: BeforeUnloadEvent): void => {
|
||||||
|
if (!draft.dirty) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = L("B08_Quantity_Unsaved");
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", warnUnsaved);
|
||||||
|
|
||||||
let workflowState: Awaited<ReturnType<typeof fetchWorkflowState>> | undefined;
|
let workflowState: Awaited<ReturnType<typeof fetchWorkflowState>> | undefined;
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
try {
|
try {
|
||||||
@@ -168,7 +323,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
|||||||
title: L("B08_Quantity_Title"),
|
title: L("B08_Quantity_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 5,
|
activeStep: 5,
|
||||||
leftPanel: buildQuantitySidePanel(projectId, table),
|
leftPanel: buildQuantitySidePanel(projectId, table, draft, reload),
|
||||||
mainContent: buildQuantityBody(table, failed),
|
mainContent: buildQuantityBody(table, failed),
|
||||||
stages: workflowState?.stages,
|
stages: workflowState?.stages,
|
||||||
currentStage: workflowState?.current_stage,
|
currentStage: workflowState?.current_stage,
|
||||||
|
|||||||
@@ -619,6 +619,23 @@ export const ui_locales_b2 = {
|
|||||||
"토적표를 불러오지 못했습니다.",
|
"토적표를 불러오지 못했습니다.",
|
||||||
"Failed to load the earthwork table.",
|
"Failed to load the earthwork table.",
|
||||||
],
|
],
|
||||||
|
B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"],
|
||||||
|
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
|
||||||
|
B08_Quantity_Haul_Missing: [
|
||||||
|
"운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.",
|
||||||
|
"No haul plan yet. Press [Confirm] on the profile design to build it.",
|
||||||
|
],
|
||||||
|
B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"],
|
||||||
|
B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"],
|
||||||
|
B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"],
|
||||||
|
B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"],
|
||||||
|
B08_Quantity_Btn_Save: ["저장", "Save"],
|
||||||
|
B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."],
|
||||||
|
B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."],
|
||||||
|
B08_Quantity_Unsaved: [
|
||||||
|
"저장하지 않은 변경이 있습니다.",
|
||||||
|
"You have unsaved changes.",
|
||||||
|
],
|
||||||
B08_Quantity_Side_Method: ["산출법", "Method"],
|
B08_Quantity_Side_Method: ["산출법", "Method"],
|
||||||
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
|
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
|
||||||
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
|
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
|
||||||
|
|||||||
Reference in New Issue
Block a user