refactor(b08): 탭 표 자료 읽기 · 표 짜기를 화면 없는 파일로 꺼냄 — 구조물 집계표 · 자재 · 토공 · 수리 입력 · 거리이정표 · 화면은 같은 함수를 씀 · 동작 그대로 (302-3)
- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table - 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Earthwork_Tables.ts
|
||||
* 토공 보조 탭 넷(PLAN 61-3 · 69-2) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3).
|
||||
* 화면(`…_UI_Earthwork` · `…_UI_Earthwork_Sheets`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
|
||||
*
|
||||
* `loadEarthwork` = GET …/quantity/earthwork + 측점 간격 · `applyRock` = 연암 · 경암 = 암 × 좌측 패널 비율 ·
|
||||
* 암 C = 비율 가중(캐시 → 저장본 · `…_Options_Calc` · PLAN 122 · 125-1)
|
||||
* 토적표 = 견본 차례 32칸 · 합계 줄 · 첫 측점은 앞이 없어 입적 · 면적이 빈 값
|
||||
* 토공집계표 · 평균운반거리 · 종방향 무대운반거리 = 거창 실무 워크북 같은 이름 시트 칸
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import {
|
||||
mixedRockC,
|
||||
recountAdjustedSummary,
|
||||
recountRockC,
|
||||
splitRockColumns,
|
||||
splitRockSummary,
|
||||
} from "./B08_Quantity_Options_Calc";
|
||||
import { stationInterval } from "./B08_Quantity_Summary_Table";
|
||||
import { liveSoftRock } from "./B08_Quantity_UI_Options";
|
||||
|
||||
type Face = { length_m: number | null; area_m2: number | null };
|
||||
|
||||
interface EarthworkRow {
|
||||
chainage_m: number;
|
||||
distance_m: number;
|
||||
cut_soil_area_m2: number;
|
||||
cut_soil_volume_m3: number;
|
||||
cut_soil_adjusted_m3: number;
|
||||
cut_rock_area_m2: number;
|
||||
cut_rock_volume_m3: number;
|
||||
cut_rock_adjusted_m3: number;
|
||||
cut_rock_kind: string;
|
||||
ditch_soil_area_m2: number;
|
||||
ditch_soil_volume_m3: number;
|
||||
ditch_soil_adjusted_m3: number;
|
||||
ditch_rock_area_m2: number;
|
||||
ditch_rock_volume_m3: number;
|
||||
ditch_rock_adjusted_m3: number;
|
||||
adjusted_total_m3: number;
|
||||
fill_area_m2: number;
|
||||
fill_volume_m3: number;
|
||||
diverted_m3: number;
|
||||
balance_m3: number;
|
||||
cumulative_m3: number;
|
||||
notes: string[];
|
||||
faces: Record<string, Face>;
|
||||
}
|
||||
|
||||
export interface HaulRow {
|
||||
q: number;
|
||||
l: number;
|
||||
ql: number;
|
||||
}
|
||||
|
||||
export interface HaulList {
|
||||
rows: HaulRow[];
|
||||
q: number;
|
||||
ql: number;
|
||||
average_m: number | null;
|
||||
}
|
||||
|
||||
export interface SummaryRow {
|
||||
group: string;
|
||||
kind: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
value: number | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface EarthworkTable {
|
||||
rows: EarthworkRow[];
|
||||
totals: Record<string, number>;
|
||||
conversion_factors: Record<string, { compacted: number }>;
|
||||
station_count: number;
|
||||
route_id: number;
|
||||
confirmed: boolean;
|
||||
summary: SummaryRow[];
|
||||
haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>;
|
||||
haul_saved: boolean;
|
||||
/** 저장된 연암 · 경암 %(암 전체 기준) */
|
||||
rock_ratios_pct: { 연암: number; 경암: number };
|
||||
/** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */
|
||||
rock_c: { ripping_rock: number; blasting_rock: number };
|
||||
}
|
||||
|
||||
/** 응답 + 측점 간격 · applyRock = 지금 좌측 패널 비율로 표를 고쳐 씀(조작마다 다시) */
|
||||
export async function loadEarthwork(
|
||||
projectId: string,
|
||||
): Promise<{ table: EarthworkTable; interval: number; applyRock: () => void }> {
|
||||
const [response, interval] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
]);
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
(EarthworkTable & { detail?: unknown }) | null;
|
||||
if (!response.ok || !body) {
|
||||
const detail = body?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
const table: EarthworkTable = body;
|
||||
// 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1)
|
||||
const summary = table.summary;
|
||||
const applyRock = (): void => {
|
||||
const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90);
|
||||
const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock);
|
||||
for (const kind of ["ripping_rock", "blasting_rock"])
|
||||
table.conversion_factors[kind].compacted = c;
|
||||
recountRockC(table.rows as unknown as Array<Record<string, unknown>>, table.totals, c);
|
||||
for (const record of [...table.rows, table.totals])
|
||||
splitRockColumns(record as unknown as Record<string, unknown>, soft);
|
||||
table.summary = recountAdjustedSummary(
|
||||
splitRockSummary(summary, table.totals, soft),
|
||||
table.totals,
|
||||
table.haul.free_haul.q,
|
||||
);
|
||||
};
|
||||
applyRock();
|
||||
return { table, interval, applyRock };
|
||||
}
|
||||
|
||||
/** 보조 탭 넷 — 화면 탭 줄 · 인쇄 쪽 차례 */
|
||||
export const EARTHWORK_SHEETS = [
|
||||
{ id: "summary", label: "토공집계표" },
|
||||
{ id: "sheet", label: "토적표" },
|
||||
{ id: "haul", label: "평균운반거리" },
|
||||
{ id: "free", label: "종방향 무대운반거리" },
|
||||
] as const;
|
||||
|
||||
/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */
|
||||
const TRIPLES: Array<[string[], string, string?]> = [
|
||||
[["절토", "토사"], "cut_soil"],
|
||||
[["절토", "연암"], "cut_soft_rock", "암 × 연암 %"],
|
||||
[["절토", "경암"], "cut_hard_rock", "암 × 경암 %"],
|
||||
[["측구파기", "토사"], "ditch_soil"],
|
||||
[["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"],
|
||||
];
|
||||
/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */
|
||||
const FACES: Array<[string[], string]> = [
|
||||
[["면고르기", "성토면"], "grading_fill"],
|
||||
[["면고르기", "절토면"], "grading_cut"],
|
||||
[["지장목제거", "성토"], "tree_removal_fill"],
|
||||
[["지장목제거", "절토"], "tree_removal_cut"],
|
||||
[["제근"], "grubbing"],
|
||||
[["노면정리"], "road_surface"],
|
||||
];
|
||||
|
||||
const n2 = (key: string, label: string, group?: string[]): DataColumn => ({
|
||||
key,
|
||||
label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals: 2,
|
||||
});
|
||||
|
||||
/** 견본 차례 32칸 */
|
||||
const COLUMNS: DataColumn[] = [
|
||||
{ key: "station", label: "측점", width: 72 },
|
||||
n2("distance", "거리"),
|
||||
...TRIPLES.flatMap(([group, key, tip]) =>
|
||||
[
|
||||
n2(`${key}_area`, "단면적", group),
|
||||
n2(`${key}_volume`, "입적", group),
|
||||
n2(`${key}_adjusted`, "보정량", group),
|
||||
].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)),
|
||||
),
|
||||
n2("adjusted_total", "보정량계"),
|
||||
n2("fill_area", "단면적", ["성토"]),
|
||||
n2("fill_volume", "입적", ["성토"]),
|
||||
n2("diverted", "유용토"),
|
||||
n2("balance", "차인토량"),
|
||||
n2("cumulative", "누가토량"),
|
||||
...FACES.flatMap(([group, key]) => [
|
||||
n2(`${key}_length`, "거리", group),
|
||||
n2(`${key}_area`, "면적", group),
|
||||
]),
|
||||
];
|
||||
|
||||
function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow {
|
||||
// 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값)
|
||||
const v = (value: number): number | null => (first ? null : value);
|
||||
const out: DataRow = {
|
||||
station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""),
|
||||
distance: row.distance_m,
|
||||
adjusted_total: v(row.adjusted_total_m3),
|
||||
fill_area: row.fill_area_m2,
|
||||
fill_volume: v(row.fill_volume_m3),
|
||||
diverted: v(row.diverted_m3),
|
||||
balance: v(row.balance_m3),
|
||||
cumulative: v(row.cumulative_m3),
|
||||
};
|
||||
const r = row as unknown as Record<string, number>;
|
||||
for (const [, key] of TRIPLES) {
|
||||
out[`${key}_area`] = r[`${key}_area_m2`];
|
||||
out[`${key}_volume`] = v(r[`${key}_volume_m3`]);
|
||||
out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]);
|
||||
}
|
||||
for (const [, key] of FACES) {
|
||||
out[`${key}_length`] = row.faces?.[key]?.length_m ?? null;
|
||||
out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */
|
||||
function totalRow(table: EarthworkTable): DataRow {
|
||||
const t = table.totals;
|
||||
const out: DataRow = {
|
||||
station: "합계",
|
||||
distance: t.distance_m,
|
||||
adjusted_total: t.adjusted_total_m3,
|
||||
fill_volume: t.fill_volume_m3,
|
||||
diverted: t.diverted_m3,
|
||||
balance: t.balance_m3,
|
||||
cumulative: table.rows[table.rows.length - 1]?.cumulative_m3,
|
||||
};
|
||||
for (const [, key] of TRIPLES) {
|
||||
out[`${key}_volume`] = t[`${key}_volume_m3`];
|
||||
out[`${key}_adjusted`] = t[`${key}_adjusted_m3`];
|
||||
}
|
||||
// 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움
|
||||
for (const [, key] of FACES) {
|
||||
const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null);
|
||||
out[`${key}_area`] = any ? t[`${key}_area_m2`] : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 토적표 */
|
||||
export const earthworkSheetOptions = (
|
||||
table: EarthworkTable,
|
||||
interval: number,
|
||||
): DataTableOptions => ({
|
||||
columns: COLUMNS,
|
||||
rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)),
|
||||
frozen: 1,
|
||||
total: totalRow(table),
|
||||
caption: "토적표",
|
||||
});
|
||||
|
||||
const SUMMARY_COLUMNS: DataColumn[] = [
|
||||
{ key: "group", label: "구분", width: 120 },
|
||||
{ key: "kind", label: "공종", width: 72 },
|
||||
{ key: "spec", label: "규격", width: 128 },
|
||||
{ key: "unit", label: "단위", width: 56 },
|
||||
{ key: "value", label: "계", kind: "number", decimals: 2, width: 112 },
|
||||
{ key: "note", label: "비고", width: 200 },
|
||||
];
|
||||
|
||||
/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */
|
||||
export const earthworkSummaryOptions = (table: EarthworkTable): DataTableOptions => ({
|
||||
columns: SUMMARY_COLUMNS,
|
||||
// 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림
|
||||
rows: table.summary.map((row) => ({
|
||||
...row,
|
||||
kind: row.kind || " ",
|
||||
spec: row.spec || " ",
|
||||
note: row.note || " ",
|
||||
})),
|
||||
frozen: 1,
|
||||
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
|
||||
caption: "토공집계표",
|
||||
});
|
||||
|
||||
const fmt = (value: number | null | undefined): string =>
|
||||
value == null || !Number.isFinite(value)
|
||||
? "-"
|
||||
: value.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
|
||||
/** 운반 시트 둘 — [묶음 이름, 목록] · 표 이름 */
|
||||
export const HAUL_SHEETS = {
|
||||
haul: (table: EarthworkTable) => ({
|
||||
groups: [
|
||||
["덤프트럭", table.haul.dump_truck],
|
||||
["불도우저", table.haul.dozer],
|
||||
] as Array<[string, HaulList]>,
|
||||
caption: "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m",
|
||||
}),
|
||||
free: (table: EarthworkTable) => ({
|
||||
groups: [["종방향무대", table.haul.free_haul]] as Array<[string, HaulList]>,
|
||||
caption: "종방향 무대운반거리 — 단위 ㎥ · m",
|
||||
}),
|
||||
};
|
||||
|
||||
/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · averages = 아래 평균운반거리 줄 */
|
||||
export function haulOptions(
|
||||
groups: Array<[string, HaulList]>,
|
||||
caption: string,
|
||||
): { options: DataTableOptions; averages: string[] } {
|
||||
const columns: DataColumn[] = [
|
||||
{ key: "no", label: "번호", width: 56 },
|
||||
...groups.flatMap(([label], g) =>
|
||||
[
|
||||
["q", "토량(Q)"],
|
||||
["l", "거리(L)"],
|
||||
["ql", "Q×L"],
|
||||
].map(([key, text]) => ({
|
||||
key: `${key}${g}`,
|
||||
label: text,
|
||||
group: [label],
|
||||
kind: "number" as const,
|
||||
decimals: 2,
|
||||
width: 104,
|
||||
})),
|
||||
),
|
||||
];
|
||||
const count = Math.max(0, ...groups.map(([, list]) => list.rows.length));
|
||||
const rows: DataRow[] = Array.from({ length: count }, (_, i) => {
|
||||
const out: DataRow = { no: String(i + 1) };
|
||||
groups.forEach(([, list], g) => {
|
||||
const item = list.rows[i];
|
||||
if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql });
|
||||
});
|
||||
return out;
|
||||
});
|
||||
const total: DataRow = { no: "합계" };
|
||||
groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql }));
|
||||
const averages = groups.map(
|
||||
([label, list], i) =>
|
||||
`${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`,
|
||||
);
|
||||
return { options: { columns, rows, frozen: 1, total, caption }, averages };
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Hydro_Table.ts
|
||||
* 수리집수면적표(PLAN 62-2 · 69-4) 자료 읽기 · 측점 입력 겹치기 · 인쇄 표 — 화면 없음(PLAN 302-3).
|
||||
* 화면(`B08_Quantity_UI_Hydro`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 입력 · 같은 서버 계산을 씀.
|
||||
*
|
||||
* 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안(캐시 `b08-hydro`) · 연결값과 같은 칸은 null 로 저장(연동 유지)
|
||||
* 계산 = 서버 단독(POST …/quantity/hydro/calc)
|
||||
* `hydroTable` = 계산서 한 장(화면 두 카드 칸 차례 그대로)을 구분 · 항목 · 값 표로 — 인쇄 전용
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_Material_Tables";
|
||||
import { stationInterval } from "./B08_Quantity_Summary_Table";
|
||||
import type {
|
||||
HydroInputs,
|
||||
HydroLink,
|
||||
HydroOptions,
|
||||
HydroResult,
|
||||
} from "./B08_Quantity_UI_Hydro_Sheet";
|
||||
|
||||
export interface HydroStation {
|
||||
chainage_m: number;
|
||||
facility: string;
|
||||
/** 박스암거 · 세월교 · 물넘이포장(관이면 "") */
|
||||
kind: string;
|
||||
/** 계산서 장 — 시설이 정함(서버 station_rows) */
|
||||
sheet: HydroInputs["sheet"];
|
||||
/** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */
|
||||
linked: HydroLink["values"] | null;
|
||||
b05: HydroLink["b05"];
|
||||
}
|
||||
|
||||
export type Drafts = Record<string, Partial<HydroInputs>>;
|
||||
export type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
|
||||
|
||||
export const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3);
|
||||
|
||||
const linkedOf = (station: HydroStation): Record<string, unknown> => station.linked ?? {};
|
||||
|
||||
/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */
|
||||
const layer = (station: HydroStation, values: Partial<HydroInputs> = {}) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))),
|
||||
);
|
||||
|
||||
/** 측점 입력 — 기본 < 장 기본 < 연결 < 저장 < 초안 */
|
||||
export const hydroInputs = (
|
||||
sheet: HydroPayload,
|
||||
station: HydroStation,
|
||||
drafts: Drafts,
|
||||
): HydroInputs =>
|
||||
({
|
||||
...sheet.defaults,
|
||||
...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]),
|
||||
...station.linked,
|
||||
// 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림)
|
||||
...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]),
|
||||
sheet: station.sheet,
|
||||
}) as HydroInputs;
|
||||
|
||||
/** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */
|
||||
export const unlinkedInputs = (station: HydroStation, inputs: HydroInputs): HydroInputs =>
|
||||
Object.fromEntries(
|
||||
Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]),
|
||||
) as unknown as HydroInputs;
|
||||
|
||||
export async function loadHydro(
|
||||
projectId: string,
|
||||
): Promise<{ sheet: HydroPayload; interval: number; drafts: Drafts }> {
|
||||
const [sheet, interval] = await Promise.all([
|
||||
request<HydroPayload>(`/projects/${projectId}/quantity/hydro`),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
]);
|
||||
return { sheet, interval, drafts: readState<Drafts>("b08-hydro", projectId) ?? {} };
|
||||
}
|
||||
|
||||
export const hydroCalc = (projectId: string, inputs: HydroInputs): Promise<HydroResult> =>
|
||||
request<HydroResult>(`/projects/${projectId}/quantity/hydro/calc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(inputs),
|
||||
});
|
||||
|
||||
const DASH = "-";
|
||||
const fmt = (value: number | null | undefined, digits: number, unit = ""): string =>
|
||||
value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`;
|
||||
const raw = (value: number | string | null | undefined, unit = ""): string =>
|
||||
value == null || value === "" ? DASH : `${value}${unit}`;
|
||||
|
||||
/** 계산서 한 장 → 표(구분 · 항목 · 값) — 칸 이름 · 자리수 = 화면 계산서(`…_UI_Hydro_Sheet`) */
|
||||
export function hydroTable(
|
||||
stationText: string,
|
||||
kind: string,
|
||||
inputs: HydroInputs,
|
||||
r: HydroResult | null,
|
||||
options: HydroOptions,
|
||||
): DataTableOptions {
|
||||
const site = options.site;
|
||||
const flood = "1. 홍수량 계산";
|
||||
const sheetDefaults = inputs.sheet === "pipe" ? {} : options.sheet_defaults[inputs.sheet];
|
||||
const n =
|
||||
inputs.n ?? (inputs.sheet === "pipe" ? options.pipe_kinds[inputs.pipe_kind] : sheetDefaults.n);
|
||||
const slope = [
|
||||
"하상경사 S = 1 /",
|
||||
`${raw(inputs.slope_denominator)} (${fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %")})`,
|
||||
];
|
||||
const section: Array<[string, string]> =
|
||||
inputs.sheet === "box"
|
||||
? [
|
||||
["내공 폭 B (m)", raw(inputs.box_width_m)],
|
||||
["내공 높이 H (m)", raw(inputs.box_height_m)],
|
||||
["수심비 h / H", raw(inputs.depth_ratio)],
|
||||
["수심 h (m)", fmt(r?.depth, 2)],
|
||||
["단면적", fmt(r?.section_area, 3, " ㎡")],
|
||||
["윤변", fmt(r?.perimeter, 3, " m")],
|
||||
["경심", fmt(r?.radius, 3)],
|
||||
slope as [string, string],
|
||||
["조도계수 n", raw(n)],
|
||||
]
|
||||
: inputs.sheet === "ford"
|
||||
? [
|
||||
["월류 폭 B (m)", raw(inputs.overflow_width_m)],
|
||||
slope as [string, string],
|
||||
["조도계수 n", raw(n)],
|
||||
["월류 높이 h (m)", fmt(r?.depth, 2)],
|
||||
["단면적", fmt(r?.section_area, 3, " ㎡")],
|
||||
["윤변", fmt(r?.perimeter, 3, " m")],
|
||||
["경심", fmt(r?.radius, 3)],
|
||||
]
|
||||
: [
|
||||
["관 종류", inputs.pipe_kind],
|
||||
["단면 가정", options.sections[inputs.section] ?? inputs.section],
|
||||
["단면 D (mm)", raw(inputs.diameter_mm)],
|
||||
["단면적", fmt(r?.section_area, 3, " ㎡")],
|
||||
["윤변", fmt(r?.perimeter, 3, " m")],
|
||||
["경심", fmt(r?.radius, 3)],
|
||||
slope as [string, string],
|
||||
["조도계수 n", raw(n)],
|
||||
];
|
||||
const judged = r != null && r.ok != null;
|
||||
const check =
|
||||
inputs.sheet === "box"
|
||||
? `2. 단면 검토 — ${kind}`
|
||||
: inputs.sheet === "ford"
|
||||
? `2. 월류 높이 — ${kind}`
|
||||
: "2. 단면 검토";
|
||||
const rows: Array<[string, string, string]> = [
|
||||
[flood, `Qd : 설계 홍수량 (㎥/s) = Q × ${inputs.factor.toFixed(1)}`, fmt(r?.design_flow, 2)],
|
||||
[flood, "Q : 홍수량 (㎥/s)", fmt(r?.flow, 2)],
|
||||
[flood, "f : 유출계수", raw(inputs.runoff)],
|
||||
[flood, "rt : 평균 강우강도 (mm/hr)", fmt(r?.intensity, 2)],
|
||||
[flood, "A : 유역면적 (㎢)", `${raw(inputs.area_km2)} (${fmt(r?.area_ha, 1, " ha")})`],
|
||||
[flood, "T : 홍수 도달시간 (hr)", fmt(r?.tc_hr, 3)],
|
||||
[flood, "L : 산정에서 하천시점까지 거리 (m)", raw(inputs.length_m)],
|
||||
[flood, "H : 산정에서 하천시점까지 표고차 (m)", raw(inputs.height_m)],
|
||||
[flood, "s : 지표면 경사 (H / L)", fmt(r?.slope_ground, 3)],
|
||||
[flood, "최소 도달시간 (분)", raw(inputs.tc_min_minutes)],
|
||||
[flood, "지역", site.region.name || raw(inputs.region)],
|
||||
[flood, "재현기간", `${inputs.return_period} 년`],
|
||||
[
|
||||
flood,
|
||||
"R24 : 확률강우량 (mm/day)",
|
||||
fmt(r?.r24_mm ?? site.r24[String(inputs.return_period)], 1),
|
||||
],
|
||||
[check, "총 홍수량 Qd (㎥/s)", fmt(r?.design_flow, 2)],
|
||||
...section.map(([label, value]) => [check, label, value] as [string, string, string]),
|
||||
[check, "유속 (m/s)", fmt(r?.velocity, 3)],
|
||||
[check, "유출량 (㎥/s)", fmt(r?.capacity, 3)],
|
||||
[
|
||||
check,
|
||||
"판정",
|
||||
judged
|
||||
? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}`
|
||||
: DASH,
|
||||
],
|
||||
[check, "유출율 (%)", fmt(r?.ratio_pct, 0)],
|
||||
];
|
||||
return {
|
||||
columns: [
|
||||
{ key: "group", label: "구분", width: 120 },
|
||||
{ key: "item", label: "항목", width: 260 },
|
||||
{ key: "value", label: "값", width: 240 },
|
||||
],
|
||||
rows: rows.map(([group, item, value]) => ({ group, item, value })),
|
||||
mergeSame: ["group"],
|
||||
caption: `홍수량 계산서 ${stationText}${kind ? ` ${kind}` : ""}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Material_Tables.ts
|
||||
* 자재 보조 탭 셋(PLAN 121-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3).
|
||||
* 화면(`B08_Quantity_UI_Material`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
|
||||
*
|
||||
* `loadMaterials` = GET …/quantity/materials + 캐시(`b08-materials`) + 좌측 패널 할증 → 짝 계산
|
||||
* `materialTable` = 보조 탭 한 장의 열 · 줄 · 계 · 고칠 칸 · 고친 칸 자리
|
||||
* `materialOptions` = 그 장의 공용 표 옵션(화면은 고치기 · 높이만 더함)
|
||||
* 값 없는 줄은 숨김(143-3) · 콘크리트소요는 5줄 늘 보임(163-5)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
computeMaterialSheets,
|
||||
DEDUCT_KEY,
|
||||
MIX_FIELDS,
|
||||
type Cells,
|
||||
type MaterialOverrides,
|
||||
type MaterialSheets,
|
||||
type SummarySource,
|
||||
} from "./B08_Quantity_Material_Calc";
|
||||
import { liveSurcharge } from "./B08_Quantity_UI_Options";
|
||||
|
||||
export type MaterialSheetId = "material" | "concrete" | "quantity";
|
||||
|
||||
export interface Payload {
|
||||
source: SummarySource;
|
||||
overrides: MaterialOverrides;
|
||||
/** 좌측 패널 할증 저장본(기본 채움) */
|
||||
surcharge_pct: Cells;
|
||||
/** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */
|
||||
notes?: string[];
|
||||
sheets: MaterialSheets;
|
||||
}
|
||||
|
||||
const BASIS =
|
||||
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
|
||||
|
||||
/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */
|
||||
const shown = <T>(rows: readonly T[], has: (row: T) => boolean): number[] =>
|
||||
rows.flatMap((row, i) => (has(row) ? [i] : []));
|
||||
const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null);
|
||||
|
||||
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** 저장본 + 캐시(조작 중 값) — calc = 지금 좌측 패널 할증으로 세 표를 다시 셈 */
|
||||
export async function loadMaterials(projectId: string): Promise<{
|
||||
payload: Payload;
|
||||
overrides: MaterialOverrides;
|
||||
calc: (payload: Payload, overrides: MaterialOverrides) => MaterialSheets;
|
||||
}> {
|
||||
const payload = await request<Payload>(`/projects/${projectId}/quantity/materials`);
|
||||
const draft = readState<MaterialOverrides>("b08-materials", projectId);
|
||||
return {
|
||||
payload,
|
||||
overrides: draft ?? payload.overrides,
|
||||
calc: (payload, overrides) =>
|
||||
computeMaterialSheets(
|
||||
payload.source,
|
||||
overrides,
|
||||
liveSurcharge(projectId, payload.surcharge_pct),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */
|
||||
const numberColumn = (column: DataColumn): DataColumn => ({
|
||||
kind: "number",
|
||||
decimals: 3,
|
||||
trim: true,
|
||||
...column,
|
||||
});
|
||||
|
||||
export interface Table {
|
||||
columns: DataColumn[];
|
||||
rows: DataRow[];
|
||||
total: DataRow | null;
|
||||
frozen: number;
|
||||
editable: (index: number, key: string) => boolean;
|
||||
/** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */
|
||||
target: (index: number, key: string) => [Record<string, Cells> | Cells, string, string | null];
|
||||
/** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { materials, rows, total } = s.quantity;
|
||||
const keep = shown(rows, (row) => row.qty != null || anyValue(row.units));
|
||||
const rowsOut: DataRow[] = keep.flatMap((at) => {
|
||||
const row = rows[at];
|
||||
const unit: DataRow = {
|
||||
name: row.name,
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
unit: row.unit,
|
||||
kind: "단위수량",
|
||||
};
|
||||
const amount: DataRow = { kind: "수량" };
|
||||
for (const m of materials) {
|
||||
unit[`m:${m.key}`] = row.units[m.key];
|
||||
amount[`m:${m.key}`] = row.amounts[m.key];
|
||||
}
|
||||
return [unit, amount];
|
||||
});
|
||||
const totalRow: DataRow = { name: "계", kind: "수량" };
|
||||
for (const m of materials) totalRow[`m:${m.key}`] = total[m.key];
|
||||
return {
|
||||
columns: [
|
||||
{ key: "name", label: "구분", width: 120 },
|
||||
{ key: "spec", label: "규격", width: 120 },
|
||||
numberColumn({ key: "qty", label: "수량", width: 72 }),
|
||||
{ key: "unit", label: "단위", width: 56 },
|
||||
{ key: "kind", label: "구분", width: 72 },
|
||||
...materials.map((m) =>
|
||||
numberColumn({
|
||||
key: `m:${m.key}`,
|
||||
label: m.unit,
|
||||
group: m.spec ? [m.name, m.spec] : [m.name],
|
||||
title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "),
|
||||
width: 76,
|
||||
}),
|
||||
),
|
||||
],
|
||||
rows: rowsOut,
|
||||
total: totalRow,
|
||||
frozen: 5,
|
||||
editable: (i, key) => i % 2 === 0 && key.startsWith("m:"),
|
||||
target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)],
|
||||
};
|
||||
}
|
||||
|
||||
export const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = {
|
||||
cement: ["시멘트", "㎥당(kg)", "수량(kg)"],
|
||||
sand: ["모래", "㎥당(㎥)", "수량(㎥)"],
|
||||
gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"],
|
||||
};
|
||||
|
||||
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows, total } = s.concrete;
|
||||
const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침
|
||||
const rowsOut: DataRow[] = keep.map((at) => {
|
||||
const row = rows[at];
|
||||
const out: DataRow = {
|
||||
name: row.name, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
};
|
||||
for (const f of MIX_FIELDS) {
|
||||
out[`per:${f}`] = row.per[f];
|
||||
out[`amt:${f}`] = row.amounts[f];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const totalRow: DataRow = { name: "계" };
|
||||
for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f];
|
||||
return {
|
||||
columns: [
|
||||
{ key: "name", label: "구분", width: 88 },
|
||||
{ key: "spec", label: "규격", width: 110 },
|
||||
numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }),
|
||||
...MIX_FIELDS.flatMap((f) => [
|
||||
numberColumn({
|
||||
key: `per:${f}`,
|
||||
label: MIX_LABEL[f][1],
|
||||
group: [MIX_LABEL[f][0]],
|
||||
width: 84,
|
||||
}),
|
||||
numberColumn({
|
||||
key: `amt:${f}`,
|
||||
label: MIX_LABEL[f][2],
|
||||
group: [MIX_LABEL[f][0]],
|
||||
width: 96,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
rows: rowsOut,
|
||||
total: totalRow,
|
||||
frozen: 2,
|
||||
mergeSame: ["name"],
|
||||
editable: (_i, key) => key.startsWith("per:"),
|
||||
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
|
||||
};
|
||||
}
|
||||
|
||||
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows } = s.material;
|
||||
const lost = new Set(s.quantity.unmatched);
|
||||
const keep = shown(rows, (row) => row.qty != null || row.amount != null);
|
||||
return {
|
||||
columns: [
|
||||
{ key: "group", label: "구분", width: 96 },
|
||||
{ key: "spec", label: "규격", width: 120 },
|
||||
{ key: "unit", label: "단위", width: 56 },
|
||||
numberColumn({ key: "qty", label: "수량", width: 96 }),
|
||||
numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }),
|
||||
numberColumn({ key: "amount", label: "합계", width: 96 }),
|
||||
{ key: "note", label: "비고", width: 96 },
|
||||
],
|
||||
rows: keep.map((at) => {
|
||||
const row = rows[at];
|
||||
return {
|
||||
group: row.group, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
surcharge: row.surcharge,
|
||||
amount: row.amount,
|
||||
note: row.panel
|
||||
? "할증 = 좌측 패널"
|
||||
: row.key === DEDUCT_KEY
|
||||
? "토공 잔토처리에서 뺌"
|
||||
: lost.has(row.key)
|
||||
? "2공구 맞춤 없음"
|
||||
: "",
|
||||
};
|
||||
}),
|
||||
total: null,
|
||||
frozen: 2,
|
||||
mergeSame: ["group"],
|
||||
editable: (i, key) =>
|
||||
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
|
||||
target: (i) => [o.surcharge, rows[keep[i]].key, null],
|
||||
};
|
||||
}
|
||||
|
||||
const BUILD: Record<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => Table> = {
|
||||
material: totalTable,
|
||||
concrete: concreteTable,
|
||||
quantity: quantityTable,
|
||||
};
|
||||
|
||||
export const materialTable = (
|
||||
sheet: MaterialSheetId,
|
||||
s: MaterialSheets,
|
||||
o: MaterialOverrides,
|
||||
): Table => BUILD[sheet](s, o);
|
||||
|
||||
/** 공용 표 옵션 — 고치기 · 높이는 화면이 더함 */
|
||||
export const materialOptions = (built: Table): DataTableOptions => ({
|
||||
columns: built.columns,
|
||||
rows: built.rows,
|
||||
total: built.total,
|
||||
frozen: built.frozen,
|
||||
mergeSame: built.mergeSame,
|
||||
empty: "",
|
||||
caption: BASIS,
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Milestone_Table.ts
|
||||
* 거리이정표 표(PLAN 244-1 · 244-13) 짜기 — 화면 없음(PLAN 302-3).
|
||||
* 화면(`B08_Quantity_UI_Milestone`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
|
||||
*
|
||||
* 열 = 기준점 있음 = 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계
|
||||
* 줄 = TS 짝(`…_Milestone_Calc.computeMilestone`) · 목적지 = 글(화면은 고름 표 ▾ 를 덧붙임)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataColumn, DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import {
|
||||
computeMilestone,
|
||||
type Dest,
|
||||
type MilestoneData,
|
||||
type MilestoneTableRow,
|
||||
} from "./B08_Quantity_Milestone_Calc";
|
||||
|
||||
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
|
||||
|
||||
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
|
||||
const ROAD = ["도로 구분 (km)"];
|
||||
const SUM = ["계 (km)"];
|
||||
|
||||
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
|
||||
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
|
||||
export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] {
|
||||
const roads: DataColumn[] = withBase
|
||||
? [
|
||||
{ key: "s2", label: "2차포장", group: ROAD, ...KM },
|
||||
{ key: "s1", label: "1차포장", group: ROAD, ...KM },
|
||||
...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []),
|
||||
{ key: "un", label: "비포장", group: ROAD, ...KM },
|
||||
{ key: "paved", label: "포장", group: SUM, ...KM },
|
||||
{ key: "unpaved", label: "비포장", group: SUM, ...KM },
|
||||
]
|
||||
: [
|
||||
{ key: "paved", label: "포장", group: ROAD, ...KM },
|
||||
{ key: "un", label: "비포장", group: ROAD, ...KM },
|
||||
];
|
||||
return [
|
||||
{ key: "item", label: "항목", width: 134 },
|
||||
{ key: "company", label: "거래처 이름", width: 120 },
|
||||
{ key: "dest", label: "목적지", width: 72 },
|
||||
...roads,
|
||||
{ key: "total", label: "합계", group: SUM, ...KM, width: 108 },
|
||||
{ key: "note", label: "비고", width: 160 },
|
||||
];
|
||||
}
|
||||
|
||||
/** 표 한 벌 — options(열 · 줄 · 이름) · rows = 짝 계산 줄(경로 id · 줄 차례 · km) · mode = 열 꼴 표 */
|
||||
export function milestoneTable(
|
||||
data: MilestoneData,
|
||||
midDefaultM: number | null,
|
||||
): { options: DataTableOptions; rows: MilestoneTableRow[]; mode: string } {
|
||||
const rows = computeMilestone(data, midDefaultM);
|
||||
const withBase = data.bases.length > 0;
|
||||
const anyPaved = rows.some((r) => r.pv > 0);
|
||||
return {
|
||||
options: {
|
||||
columns: milestoneColumns(withBase, anyPaved),
|
||||
rows: rows.map((r) => ({ ...r, dest: DEST_TEXT[r.dest] })),
|
||||
caption: "거리이정표",
|
||||
empty: "",
|
||||
},
|
||||
rows,
|
||||
mode: `${withBase}:${anyPaved}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Summary_Table.ts
|
||||
* 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3).
|
||||
* 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
|
||||
*
|
||||
* `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로
|
||||
* `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class)
|
||||
* 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import {
|
||||
comboId,
|
||||
linkedOnly,
|
||||
pipeOwnerId,
|
||||
summaryColumns,
|
||||
summaryStations,
|
||||
type ComboColumn,
|
||||
type ComboLabel,
|
||||
type LinkedSnapshot,
|
||||
type PlacedItem,
|
||||
type UnlinkedFacility,
|
||||
unassignedText,
|
||||
unlinkedAt,
|
||||
widthOf,
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
|
||||
import { liveOnePipe } from "./B08_Quantity_UI_Options";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */
|
||||
export async function stationInterval(projectId: string): Promise<number> {
|
||||
const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId));
|
||||
return latest.route_params?.station_interval_m || 20;
|
||||
}
|
||||
|
||||
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
|
||||
interface PipeColumn {
|
||||
id: string;
|
||||
group: string[];
|
||||
label: string;
|
||||
combos: string[];
|
||||
}
|
||||
export interface PipeSummary {
|
||||
columns: PipeColumn[];
|
||||
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
|
||||
total: Record<string, number>;
|
||||
notes: string[];
|
||||
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
|
||||
types: string[];
|
||||
one_length_m: number | null;
|
||||
pipes: PipeLength[];
|
||||
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
/** 미연결 시설(154-1) */
|
||||
unlinked: UnlinkedFacility[];
|
||||
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
|
||||
facilities: UnlinkedFacility[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const PIPE_TOP = "관 공";
|
||||
/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */
|
||||
export const LAMP_CLASS = "b08-summary__lamp";
|
||||
/** 모두 연결 = 같은 램프 초록(247-1) */
|
||||
const LINKED_CLASS = `${LAMP_CLASS} is-linked`;
|
||||
const LAMP_W = 14;
|
||||
const stationKey = (station: number): number => Math.round(station * 100);
|
||||
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
|
||||
const placesOf = (values: number[]): number =>
|
||||
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
|
||||
|
||||
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */
|
||||
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
|
||||
const top =
|
||||
summary.columns
|
||||
.filter((column) => column.group[0] === PIPE_TOP)
|
||||
.flatMap((column) => column.combos)
|
||||
.map(comboPath)
|
||||
.find((path) => !!path) ?? PIPE_TOP;
|
||||
return summary.columns.map((column) => {
|
||||
const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group;
|
||||
const values = [
|
||||
...summary.rows.map((row) => row.values[column.id]),
|
||||
summary.total[column.id],
|
||||
].filter((value): value is number => typeof value === "number");
|
||||
const decimals = placesOf(values);
|
||||
const texts = values.map((value) =>
|
||||
value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
key: column.id,
|
||||
label: column.label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals,
|
||||
title: [...group, column.label].join(" › "),
|
||||
width: widthOf(column.label, ...texts),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */
|
||||
export interface SummaryView {
|
||||
stations: number[];
|
||||
/** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */
|
||||
columns: ComboColumn[];
|
||||
/** 모달용 구조물 · 관 지점(사본 있는 것) */
|
||||
things: PlacedItem[];
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
describe: (item: SummaryItem) => ComboLabel | null;
|
||||
comboPath: (id: string) => string | null;
|
||||
interval: number;
|
||||
unlinked: UnlinkedFacility[];
|
||||
saved: PipeSummary | null;
|
||||
/** 배관 집계 실패 까닭 — 나머지 표는 그대로 */
|
||||
error: string | null;
|
||||
/** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */
|
||||
live: () => PipeSummary | null;
|
||||
}
|
||||
|
||||
export async function loadSummary(projectId: string): Promise<SummaryView> {
|
||||
const [structures, pipes, interval, pipe] = await Promise.all([
|
||||
fetchStructures(projectId),
|
||||
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
|
||||
fetchDetailPipePoints(projectId).then(
|
||||
(response) => response.pipe_points,
|
||||
() => [],
|
||||
),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
|
||||
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
|
||||
]);
|
||||
const saved = typeof pipe === "string" ? null : pipe;
|
||||
// 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1)
|
||||
const linked = saved?.linked ?? {};
|
||||
const paths = new Map(
|
||||
Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const),
|
||||
);
|
||||
const describe = (item: SummaryItem): ComboLabel | null => {
|
||||
const [group = "", ...rest] = paths.get(comboId(item)) ?? [];
|
||||
return group ? { group, name: rest.join(" ") || group } : null;
|
||||
};
|
||||
const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null;
|
||||
// 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1)
|
||||
const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)];
|
||||
const placed = linkedOnly(
|
||||
pipes.map((point) => ({
|
||||
...point,
|
||||
structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m),
|
||||
})),
|
||||
linked,
|
||||
);
|
||||
const mine = linkedOnly(structures.structures, linked);
|
||||
const things: PlacedItem[] = [...mine, ...placed];
|
||||
const unlinked = saved?.unlinked ?? [];
|
||||
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
const served = new Set(saved?.types ?? []);
|
||||
const others = [
|
||||
...mine.filter((item) => !served.has(item.type_id)),
|
||||
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
|
||||
];
|
||||
return {
|
||||
stations: summaryStations([...things, ...unlinked]),
|
||||
columns: summaryColumns(others, describe),
|
||||
things,
|
||||
linked,
|
||||
describe,
|
||||
comboPath,
|
||||
interval,
|
||||
unlinked,
|
||||
saved,
|
||||
error: typeof pipe === "string" ? pipe : null,
|
||||
live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */
|
||||
export function summaryTable(view: SummaryView): {
|
||||
options: DataTableOptions;
|
||||
labels: string[];
|
||||
missing: string[];
|
||||
} {
|
||||
const { stations, columns, interval, unlinked, comboPath } = view;
|
||||
const pipe = view.live();
|
||||
const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values]));
|
||||
const total: DataRow | null = pipe?.columns.length
|
||||
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
|
||||
: null;
|
||||
const labels = stations.map((station) => stationLabel(station, interval));
|
||||
const stationHead = L("B08_Quantity_Summary_Station");
|
||||
// 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2)
|
||||
const missing = stations.map((station) =>
|
||||
unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")),
|
||||
);
|
||||
// 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프
|
||||
const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0);
|
||||
const options: DataTableOptions = {
|
||||
columns: [
|
||||
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W },
|
||||
...columns.map((column) => ({
|
||||
key: column.id,
|
||||
label: column.name,
|
||||
group: [column.group || L("B08_Quantity_Summary_MissingGroup")],
|
||||
title: summaryTooltip(column.item),
|
||||
className: column.missing ? "is-missing" : undefined,
|
||||
width: widthOf(column.name),
|
||||
})),
|
||||
...(pipe ? pipeColumns(pipe, comboPath) : []),
|
||||
],
|
||||
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
|
||||
rows: stations.map((station, i) => ({
|
||||
station: labels[i],
|
||||
...values.get(stationKey(station)),
|
||||
})),
|
||||
total,
|
||||
cellClass: (i, key) =>
|
||||
key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null,
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
caption: L("B08_Quantity_Tab_StructureSummary"),
|
||||
};
|
||||
return { options, labels, missing };
|
||||
}
|
||||
@@ -9,142 +9,24 @@
|
||||
* 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`.
|
||||
* 절토 연암 · 경암 = 암 × 좌측 패널 비율 · 암 C = 비율 가중 — 조작 중 같은 식으로 바로 다시 셈
|
||||
* (`…_Options_Calc` · PLAN 122 · 125-1).
|
||||
* 자료 읽기 · 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, createCard } from "@ui/ui_template_elements";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
import { stationInterval } from "./B08_Quantity_UI_Summary";
|
||||
import {
|
||||
EARTHWORK_SHEETS,
|
||||
earthworkSheetOptions,
|
||||
loadEarthwork,
|
||||
type EarthworkTable,
|
||||
} from "./B08_Quantity_Earthwork_Tables";
|
||||
import {
|
||||
renderEarthworkSummary,
|
||||
renderFreeHaul,
|
||||
renderHaulDistance,
|
||||
} from "./B08_Quantity_UI_Earthwork_Sheets";
|
||||
import {
|
||||
mixedRockC,
|
||||
recountAdjustedSummary,
|
||||
recountRockC,
|
||||
splitRockColumns,
|
||||
splitRockSummary,
|
||||
} from "./B08_Quantity_Options_Calc";
|
||||
import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
type Face = { length_m: number | null; area_m2: number | null };
|
||||
|
||||
interface EarthworkRow {
|
||||
chainage_m: number;
|
||||
distance_m: number;
|
||||
cut_soil_area_m2: number;
|
||||
cut_soil_volume_m3: number;
|
||||
cut_soil_adjusted_m3: number;
|
||||
cut_rock_area_m2: number;
|
||||
cut_rock_volume_m3: number;
|
||||
cut_rock_adjusted_m3: number;
|
||||
cut_rock_kind: string;
|
||||
ditch_soil_area_m2: number;
|
||||
ditch_soil_volume_m3: number;
|
||||
ditch_soil_adjusted_m3: number;
|
||||
ditch_rock_area_m2: number;
|
||||
ditch_rock_volume_m3: number;
|
||||
ditch_rock_adjusted_m3: number;
|
||||
adjusted_total_m3: number;
|
||||
fill_area_m2: number;
|
||||
fill_volume_m3: number;
|
||||
diverted_m3: number;
|
||||
balance_m3: number;
|
||||
cumulative_m3: number;
|
||||
notes: string[];
|
||||
faces: Record<string, Face>;
|
||||
}
|
||||
|
||||
export interface HaulRow {
|
||||
q: number;
|
||||
l: number;
|
||||
ql: number;
|
||||
}
|
||||
|
||||
export interface HaulList {
|
||||
rows: HaulRow[];
|
||||
q: number;
|
||||
ql: number;
|
||||
average_m: number | null;
|
||||
}
|
||||
|
||||
export interface SummaryRow {
|
||||
group: string;
|
||||
kind: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
value: number | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface EarthworkTable {
|
||||
rows: EarthworkRow[];
|
||||
totals: Record<string, number>;
|
||||
conversion_factors: Record<string, { compacted: number }>;
|
||||
station_count: number;
|
||||
route_id: number;
|
||||
confirmed: boolean;
|
||||
summary: SummaryRow[];
|
||||
haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>;
|
||||
haul_saved: boolean;
|
||||
/** 저장된 연암 · 경암 %(암 전체 기준) */
|
||||
rock_ratios_pct: { 연암: number; 경암: number };
|
||||
/** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */
|
||||
rock_c: { ripping_rock: number; blasting_rock: number };
|
||||
}
|
||||
|
||||
/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */
|
||||
const TRIPLES: Array<[string[], string, string?]> = [
|
||||
[["절토", "토사"], "cut_soil"],
|
||||
[["절토", "연암"], "cut_soft_rock", "암 × 연암 %"],
|
||||
[["절토", "경암"], "cut_hard_rock", "암 × 경암 %"],
|
||||
[["측구파기", "토사"], "ditch_soil"],
|
||||
[["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"],
|
||||
];
|
||||
/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */
|
||||
const FACES: Array<[string[], string]> = [
|
||||
[["면고르기", "성토면"], "grading_fill"],
|
||||
[["면고르기", "절토면"], "grading_cut"],
|
||||
[["지장목제거", "성토"], "tree_removal_fill"],
|
||||
[["지장목제거", "절토"], "tree_removal_cut"],
|
||||
[["제근"], "grubbing"],
|
||||
[["노면정리"], "road_surface"],
|
||||
];
|
||||
|
||||
const n2 = (key: string, label: string, group?: string[]): DataColumn => ({
|
||||
key,
|
||||
label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals: 2,
|
||||
});
|
||||
|
||||
/** 견본 차례 32칸 */
|
||||
const COLUMNS: DataColumn[] = [
|
||||
{ key: "station", label: "측점", width: 72 },
|
||||
n2("distance", "거리"),
|
||||
...TRIPLES.flatMap(([group, key, tip]) =>
|
||||
[
|
||||
n2(`${key}_area`, "단면적", group),
|
||||
n2(`${key}_volume`, "입적", group),
|
||||
n2(`${key}_adjusted`, "보정량", group),
|
||||
].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)),
|
||||
),
|
||||
n2("adjusted_total", "보정량계"),
|
||||
n2("fill_area", "단면적", ["성토"]),
|
||||
n2("fill_volume", "입적", ["성토"]),
|
||||
n2("diverted", "유용토"),
|
||||
n2("balance", "차인토량"),
|
||||
n2("cumulative", "누가토량"),
|
||||
...FACES.flatMap(([group, key]) => [
|
||||
n2(`${key}_length`, "거리", group),
|
||||
n2(`${key}_area`, "면적", group),
|
||||
]),
|
||||
];
|
||||
import { onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
export function note(text: string): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
@@ -162,56 +44,6 @@ export function tableCard(
|
||||
return createCard({ body: [table, ...after, ...info] });
|
||||
}
|
||||
|
||||
function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow {
|
||||
// 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값)
|
||||
const v = (value: number): number | null => (first ? null : value);
|
||||
const out: DataRow = {
|
||||
station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""),
|
||||
distance: row.distance_m,
|
||||
adjusted_total: v(row.adjusted_total_m3),
|
||||
fill_area: row.fill_area_m2,
|
||||
fill_volume: v(row.fill_volume_m3),
|
||||
diverted: v(row.diverted_m3),
|
||||
balance: v(row.balance_m3),
|
||||
cumulative: v(row.cumulative_m3),
|
||||
};
|
||||
const r = row as unknown as Record<string, number>;
|
||||
for (const [, key] of TRIPLES) {
|
||||
out[`${key}_area`] = r[`${key}_area_m2`];
|
||||
out[`${key}_volume`] = v(r[`${key}_volume_m3`]);
|
||||
out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]);
|
||||
}
|
||||
for (const [, key] of FACES) {
|
||||
out[`${key}_length`] = row.faces?.[key]?.length_m ?? null;
|
||||
out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */
|
||||
function totalRow(table: EarthworkTable): DataRow {
|
||||
const t = table.totals;
|
||||
const out: DataRow = {
|
||||
station: "합계",
|
||||
distance: t.distance_m,
|
||||
adjusted_total: t.adjusted_total_m3,
|
||||
fill_volume: t.fill_volume_m3,
|
||||
diverted: t.diverted_m3,
|
||||
balance: t.balance_m3,
|
||||
cumulative: table.rows[table.rows.length - 1]?.cumulative_m3,
|
||||
};
|
||||
for (const [, key] of TRIPLES) {
|
||||
out[`${key}_volume`] = t[`${key}_volume_m3`];
|
||||
out[`${key}_adjusted`] = t[`${key}_adjusted_m3`];
|
||||
}
|
||||
// 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움
|
||||
for (const [, key] of FACES) {
|
||||
const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null);
|
||||
out[`${key}_area`] = any ? t[`${key}_area_m2`] : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void {
|
||||
if (!table.rows.length) {
|
||||
host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."));
|
||||
@@ -225,66 +57,25 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
|
||||
),
|
||||
...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
|
||||
];
|
||||
const grid = createDataTable({
|
||||
columns: COLUMNS,
|
||||
rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)),
|
||||
frozen: 1,
|
||||
total: totalRow(table),
|
||||
caption: "토적표",
|
||||
fillViewport: true,
|
||||
});
|
||||
const grid = createDataTable({ ...earthworkSheetOptions(table, interval), fillViewport: true });
|
||||
grid.root.dataset.b08Earthwork = "table";
|
||||
host.replaceChildren(tableCard(info, grid.root));
|
||||
}
|
||||
|
||||
const SHEETS = [
|
||||
{ id: "summary", label: "토공집계표" },
|
||||
{ id: "sheet", label: "토적표" },
|
||||
{ id: "haul", label: "평균운반거리" },
|
||||
{ id: "free", label: "종방향 무대운반거리" },
|
||||
] as const;
|
||||
const SHEETS = EARTHWORK_SHEETS;
|
||||
|
||||
type SheetId = (typeof SHEETS)[number]["id"];
|
||||
|
||||
export async function renderEarthwork(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note("토공 수량을 만드는 중…"));
|
||||
let table: EarthworkTable;
|
||||
let interval = 20;
|
||||
let loaded: Awaited<ReturnType<typeof loadEarthwork>>;
|
||||
try {
|
||||
const [response, gap] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
]);
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
(EarthworkTable & { detail?: unknown }) | null;
|
||||
if (!response.ok || !body) {
|
||||
const detail = body?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
table = body;
|
||||
interval = gap;
|
||||
loaded = await loadEarthwork(projectId);
|
||||
} catch (error) {
|
||||
host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1)
|
||||
const summary = table.summary;
|
||||
const applyRock = (): void => {
|
||||
const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90);
|
||||
const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock);
|
||||
for (const kind of ["ripping_rock", "blasting_rock"])
|
||||
table.conversion_factors[kind].compacted = c;
|
||||
recountRockC(table.rows as unknown as Array<Record<string, unknown>>, table.totals, c);
|
||||
for (const record of [...table.rows, table.totals])
|
||||
splitRockColumns(record as unknown as Record<string, unknown>, soft);
|
||||
table.summary = recountAdjustedSummary(
|
||||
splitRockSummary(summary, table.totals, soft),
|
||||
table.totals,
|
||||
table.haul.free_haul.q,
|
||||
);
|
||||
};
|
||||
applyRock();
|
||||
const { table, interval, applyRock } = loaded;
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs";
|
||||
|
||||
@@ -5,45 +5,25 @@
|
||||
* 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`).
|
||||
* 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임.
|
||||
* 표 = 공용 많은 자료 표(`ui_template_data_table`).
|
||||
* 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { note, tableCard } from "./B08_Quantity_UI_Earthwork";
|
||||
import {
|
||||
earthworkSummaryOptions,
|
||||
HAUL_SHEETS,
|
||||
haulOptions,
|
||||
type EarthworkTable,
|
||||
} from "./B08_Quantity_Earthwork_Tables";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
|
||||
const fmt = (value: number | null | undefined): string =>
|
||||
value == null || !Number.isFinite(value)
|
||||
? "-"
|
||||
: value.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
|
||||
const SUMMARY_COLUMNS: DataColumn[] = [
|
||||
{ key: "group", label: "구분", width: 120 },
|
||||
{ key: "kind", label: "공종", width: 72 },
|
||||
{ key: "spec", label: "규격", width: 128 },
|
||||
{ key: "unit", label: "단위", width: 56 },
|
||||
{ key: "value", label: "계", kind: "number", decimals: 2, width: 112 },
|
||||
{ key: "note", label: "비고", width: 200 },
|
||||
];
|
||||
|
||||
/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */
|
||||
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
|
||||
const info = footNote(
|
||||
"연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움",
|
||||
);
|
||||
const grid = createDataTable({
|
||||
columns: SUMMARY_COLUMNS,
|
||||
// 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림
|
||||
rows: table.summary.map((row) => ({
|
||||
...row,
|
||||
kind: row.kind || " ",
|
||||
spec: row.spec || " ",
|
||||
note: row.note || " ",
|
||||
})),
|
||||
frozen: 1,
|
||||
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
|
||||
caption: "토공집계표",
|
||||
fillViewport: true,
|
||||
});
|
||||
const grid = createDataTable({ ...earthworkSummaryOptions(table), fillViewport: true });
|
||||
host.replaceChildren(tableCard([info], grid.root));
|
||||
}
|
||||
|
||||
@@ -53,74 +33,22 @@ function haulNote(table: EarthworkTable): HTMLElement[] {
|
||||
: [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")];
|
||||
}
|
||||
|
||||
/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */
|
||||
/** 운반 시트 한 장 — 표 + 아래 평균운반거리 줄 */
|
||||
function haulSheet(
|
||||
host: HTMLElement,
|
||||
table: EarthworkTable,
|
||||
groups: Array<[string, HaulList]>,
|
||||
caption: string,
|
||||
sheet: keyof typeof HAUL_SHEETS,
|
||||
): void {
|
||||
const columns: DataColumn[] = [
|
||||
{ key: "no", label: "번호", width: 56 },
|
||||
...groups.flatMap(([label], g) =>
|
||||
[
|
||||
["q", "토량(Q)"],
|
||||
["l", "거리(L)"],
|
||||
["ql", "Q×L"],
|
||||
].map(([key, text]) => ({
|
||||
key: `${key}${g}`,
|
||||
label: text,
|
||||
group: [label],
|
||||
kind: "number" as const,
|
||||
decimals: 2,
|
||||
width: 104,
|
||||
})),
|
||||
),
|
||||
];
|
||||
const count = Math.max(0, ...groups.map(([, list]) => list.rows.length));
|
||||
const rows: DataRow[] = Array.from({ length: count }, (_, i) => {
|
||||
const out: DataRow = { no: String(i + 1) };
|
||||
groups.forEach(([, list], g) => {
|
||||
const item = list.rows[i];
|
||||
if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql });
|
||||
});
|
||||
return out;
|
||||
});
|
||||
const total: DataRow = { no: "합계" };
|
||||
groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql }));
|
||||
const averages = groups.map(([label, list], i) =>
|
||||
note(
|
||||
`${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`,
|
||||
),
|
||||
);
|
||||
const grid = createDataTable({
|
||||
columns,
|
||||
rows,
|
||||
frozen: 1,
|
||||
total,
|
||||
caption,
|
||||
fillViewport: true,
|
||||
});
|
||||
host.replaceChildren(tableCard(haulNote(table), grid.root, averages));
|
||||
const { groups, caption } = HAUL_SHEETS[sheet](table);
|
||||
const { options, averages } = haulOptions(groups, caption);
|
||||
const grid = createDataTable({ ...options, fillViewport: true });
|
||||
host.replaceChildren(tableCard(haulNote(table), grid.root, averages.map(note)));
|
||||
}
|
||||
|
||||
export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void {
|
||||
haulSheet(
|
||||
host,
|
||||
table,
|
||||
[
|
||||
["덤프트럭", table.haul.dump_truck],
|
||||
["불도우저", table.haul.dozer],
|
||||
],
|
||||
"덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m",
|
||||
);
|
||||
haulSheet(host, table, "haul");
|
||||
}
|
||||
|
||||
export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void {
|
||||
haulSheet(
|
||||
host,
|
||||
table,
|
||||
[["종방향무대", table.haul.free_haul]],
|
||||
"종방향 무대운반거리 — 단위 ㎥ · m",
|
||||
);
|
||||
haulSheet(host, table, "free");
|
||||
}
|
||||
|
||||
@@ -8,55 +8,32 @@
|
||||
* 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버)
|
||||
* 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움
|
||||
* 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택)
|
||||
* 자료 읽기 · 측점 입력 겹치기 = `B08_Quantity_Hydro_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B08_Quantity_UI_Hydro.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { clearState, writeState } from "../A00_Common/b_page_state";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { stationInterval } from "./B08_Quantity_UI_Summary";
|
||||
import {
|
||||
buildHydroSheet,
|
||||
type HydroInputs,
|
||||
type HydroLink,
|
||||
type HydroOptions,
|
||||
type HydroResult,
|
||||
} from "./B08_Quantity_UI_Hydro_Sheet";
|
||||
hydroCalc,
|
||||
hydroInputs,
|
||||
keyOf,
|
||||
loadHydro,
|
||||
unlinkedInputs as unlinked,
|
||||
type Drafts,
|
||||
type HydroPayload,
|
||||
type HydroStation,
|
||||
} from "./B08_Quantity_Hydro_Table";
|
||||
import { request } from "./B08_Quantity_Material_Tables";
|
||||
import { buildHydroSheet, type HydroInputs, type HydroResult } from "./B08_Quantity_UI_Hydro_Sheet";
|
||||
|
||||
interface HydroStation {
|
||||
chainage_m: number;
|
||||
facility: string;
|
||||
/** 박스암거 · 세월교 · 물넘이포장(관이면 "") */
|
||||
kind: string;
|
||||
/** 계산서 장 — 시설이 정함(서버 station_rows) */
|
||||
sheet: HydroInputs["sheet"];
|
||||
/** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */
|
||||
linked: HydroLink["values"] | null;
|
||||
b05: HydroLink["b05"];
|
||||
}
|
||||
|
||||
type Drafts = Record<string, Partial<HydroInputs>>;
|
||||
type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
|
||||
|
||||
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
export { request };
|
||||
|
||||
function note(text: string): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
@@ -65,17 +42,13 @@ function note(text: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3);
|
||||
|
||||
export async function renderHydro(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note("홍수량 계산서를 불러오는 중…"));
|
||||
let sheet: HydroPayload;
|
||||
let interval = 20;
|
||||
let drafts: Drafts = {};
|
||||
try {
|
||||
[sheet, interval] = await Promise.all([
|
||||
request<HydroPayload>(`/projects/${projectId}/quantity/hydro`),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
]);
|
||||
({ sheet, interval, drafts } = await loadHydro(projectId));
|
||||
} catch (error) {
|
||||
host.replaceChildren(note(`홍수량 계산서를 불러오지 못했습니다 — ${String(error)}`));
|
||||
return;
|
||||
@@ -87,27 +60,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
let drafts: Drafts = readState<Drafts>("b08-hydro", projectId) ?? {};
|
||||
const linkedOf = (station: HydroStation): Record<string, unknown> => station.linked ?? {};
|
||||
/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */
|
||||
const layer = (station: HydroStation, values: Partial<HydroInputs> = {}) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))),
|
||||
);
|
||||
const inputsOf = (station: HydroStation): HydroInputs =>
|
||||
({
|
||||
...sheet.defaults,
|
||||
...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]),
|
||||
...station.linked,
|
||||
// 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림)
|
||||
...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]),
|
||||
sheet: station.sheet,
|
||||
}) as HydroInputs;
|
||||
/** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */
|
||||
const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs =>
|
||||
Object.fromEntries(
|
||||
Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]),
|
||||
) as unknown as HydroInputs;
|
||||
const inputsOf = (station: HydroStation): HydroInputs => hydroInputs(sheet, station, drafts);
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs";
|
||||
@@ -172,10 +125,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
||||
): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const result = await request<HydroResult>(`/projects/${projectId}/quantity/hydro/calc`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(inputs),
|
||||
});
|
||||
const result = await hydroCalc(projectId, inputs);
|
||||
if (mine === seq) show(result);
|
||||
} catch (error) {
|
||||
if (mine === seq) show(null, `계산하지 못했습니다 — ${String(error)}`);
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
* 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5)
|
||||
* 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른
|
||||
* 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5)
|
||||
* 자료 읽기 · 표 짜기 = `B08_Quantity_Material_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
@@ -21,36 +21,30 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { clearState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
blankUnitRows,
|
||||
computeMaterialSheets,
|
||||
DEDUCT_KEY,
|
||||
matched,
|
||||
MIX_FIELDS,
|
||||
type Cells,
|
||||
type MaterialOverrides,
|
||||
type MaterialSheets,
|
||||
type SummarySource,
|
||||
} from "./B08_Quantity_Material_Calc";
|
||||
import {
|
||||
loadMaterials,
|
||||
materialOptions,
|
||||
materialTable,
|
||||
MIX_LABEL,
|
||||
request,
|
||||
type MaterialSheetId,
|
||||
type Payload,
|
||||
} from "./B08_Quantity_Material_Tables";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
import { onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
export type MaterialSheetId = "material" | "concrete" | "quantity";
|
||||
export type { MaterialSheetId };
|
||||
|
||||
interface Payload {
|
||||
source: SummarySource;
|
||||
overrides: MaterialOverrides;
|
||||
/** 좌측 패널 할증 저장본(기본 채움) */
|
||||
surcharge_pct: Cells;
|
||||
/** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */
|
||||
notes?: string[];
|
||||
sheets: MaterialSheets;
|
||||
}
|
||||
|
||||
const BASIS =
|
||||
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
|
||||
const HINTS: Record<MaterialSheetId, string> = {
|
||||
material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널",
|
||||
concrete: "㎥당 배합은 칸에서 고침",
|
||||
@@ -58,25 +52,6 @@ const HINTS: Record<MaterialSheetId, string> = {
|
||||
};
|
||||
const HIDDEN = "값 없는 줄은 숨김";
|
||||
|
||||
/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */
|
||||
const shown = <T>(rows: readonly T[], has: (row: T) => boolean): number[] =>
|
||||
rows.flatMap((row, i) => (has(row) ? [i] : []));
|
||||
const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null);
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function note(text: string): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "ui-general-block__empty";
|
||||
@@ -84,14 +59,6 @@ function note(text: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */
|
||||
const numberColumn = (column: DataColumn): DataColumn => ({
|
||||
kind: "number",
|
||||
decimals: 3,
|
||||
trim: true,
|
||||
...column,
|
||||
});
|
||||
|
||||
/** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */
|
||||
function parse(text: string): number | null | undefined {
|
||||
const t = text.replace(/,/g, "").trim();
|
||||
@@ -100,70 +67,6 @@ function parse(text: string): number | null | undefined {
|
||||
return Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
interface Table {
|
||||
columns: DataColumn[];
|
||||
rows: DataRow[];
|
||||
total: DataRow | null;
|
||||
frozen: number;
|
||||
editable: (index: number, key: string) => boolean;
|
||||
/** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */
|
||||
target: (index: number, key: string) => [Record<string, Cells> | Cells, string, string | null];
|
||||
/** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { materials, rows, total } = s.quantity;
|
||||
const keep = shown(rows, (row) => row.qty != null || anyValue(row.units));
|
||||
const rowsOut: DataRow[] = keep.flatMap((at) => {
|
||||
const row = rows[at];
|
||||
const unit: DataRow = {
|
||||
name: row.name,
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
unit: row.unit,
|
||||
kind: "단위수량",
|
||||
};
|
||||
const amount: DataRow = { kind: "수량" };
|
||||
for (const m of materials) {
|
||||
unit[`m:${m.key}`] = row.units[m.key];
|
||||
amount[`m:${m.key}`] = row.amounts[m.key];
|
||||
}
|
||||
return [unit, amount];
|
||||
});
|
||||
const totalRow: DataRow = { name: "계", kind: "수량" };
|
||||
for (const m of materials) totalRow[`m:${m.key}`] = total[m.key];
|
||||
return {
|
||||
columns: [
|
||||
{ key: "name", label: "구분", width: 120 },
|
||||
{ key: "spec", label: "규격", width: 120 },
|
||||
numberColumn({ key: "qty", label: "수량", width: 72 }),
|
||||
{ key: "unit", label: "단위", width: 56 },
|
||||
{ key: "kind", label: "구분", width: 72 },
|
||||
...materials.map((m) =>
|
||||
numberColumn({
|
||||
key: `m:${m.key}`,
|
||||
label: m.unit,
|
||||
group: m.spec ? [m.name, m.spec] : [m.name],
|
||||
title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "),
|
||||
width: 76,
|
||||
}),
|
||||
),
|
||||
],
|
||||
rows: rowsOut,
|
||||
total: totalRow,
|
||||
frozen: 5,
|
||||
editable: (i, key) => i % 2 === 0 && key.startsWith("m:"),
|
||||
target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)],
|
||||
};
|
||||
}
|
||||
|
||||
const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = {
|
||||
cement: ["시멘트", "㎥당(kg)", "수량(kg)"],
|
||||
sand: ["모래", "㎥당(㎥)", "수량(㎥)"],
|
||||
gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"],
|
||||
};
|
||||
|
||||
/** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */
|
||||
const EXCEL_MIX: Record<string, Cells> = {
|
||||
c25: { cement: 346, sand: 0.51, gravel: 0.59 },
|
||||
@@ -214,100 +117,6 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
|
||||
: "";
|
||||
}
|
||||
|
||||
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows, total } = s.concrete;
|
||||
const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침
|
||||
const rowsOut: DataRow[] = keep.map((at) => {
|
||||
const row = rows[at];
|
||||
const out: DataRow = {
|
||||
name: row.name, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
};
|
||||
for (const f of MIX_FIELDS) {
|
||||
out[`per:${f}`] = row.per[f];
|
||||
out[`amt:${f}`] = row.amounts[f];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const totalRow: DataRow = { name: "계" };
|
||||
for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f];
|
||||
return {
|
||||
columns: [
|
||||
{ key: "name", label: "구분", width: 88 },
|
||||
{ key: "spec", label: "규격", width: 110 },
|
||||
numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }),
|
||||
...MIX_FIELDS.flatMap((f) => [
|
||||
numberColumn({
|
||||
key: `per:${f}`,
|
||||
label: MIX_LABEL[f][1],
|
||||
group: [MIX_LABEL[f][0]],
|
||||
width: 84,
|
||||
}),
|
||||
numberColumn({
|
||||
key: `amt:${f}`,
|
||||
label: MIX_LABEL[f][2],
|
||||
group: [MIX_LABEL[f][0]],
|
||||
width: 96,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
rows: rowsOut,
|
||||
total: totalRow,
|
||||
frozen: 2,
|
||||
mergeSame: ["name"],
|
||||
editable: (_i, key) => key.startsWith("per:"),
|
||||
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
|
||||
};
|
||||
}
|
||||
|
||||
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows } = s.material;
|
||||
const lost = new Set(s.quantity.unmatched);
|
||||
const keep = shown(rows, (row) => row.qty != null || row.amount != null);
|
||||
return {
|
||||
columns: [
|
||||
{ key: "group", label: "구분", width: 96 },
|
||||
{ key: "spec", label: "규격", width: 120 },
|
||||
{ key: "unit", label: "단위", width: 56 },
|
||||
numberColumn({ key: "qty", label: "수량", width: 96 }),
|
||||
numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }),
|
||||
numberColumn({ key: "amount", label: "합계", width: 96 }),
|
||||
{ key: "note", label: "비고", width: 96 },
|
||||
],
|
||||
rows: keep.map((at) => {
|
||||
const row = rows[at];
|
||||
return {
|
||||
group: row.group, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
surcharge: row.surcharge,
|
||||
amount: row.amount,
|
||||
note: row.panel
|
||||
? "할증 = 좌측 패널"
|
||||
: row.key === DEDUCT_KEY
|
||||
? "토공 잔토처리에서 뺌"
|
||||
: lost.has(row.key)
|
||||
? "2공구 맞춤 없음"
|
||||
: "",
|
||||
};
|
||||
}),
|
||||
total: null,
|
||||
frozen: 2,
|
||||
mergeSame: ["group"],
|
||||
editable: (i, key) =>
|
||||
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
|
||||
target: (i) => [o.surcharge, rows[keep[i]].key, null],
|
||||
};
|
||||
}
|
||||
|
||||
const BUILD: Record<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => Table> = {
|
||||
material: totalTable,
|
||||
concrete: concreteTable,
|
||||
quantity: quantityTable,
|
||||
};
|
||||
|
||||
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
|
||||
export async function renderMaterialSheet(
|
||||
host: HTMLElement,
|
||||
@@ -315,21 +124,15 @@ export async function renderMaterialSheet(
|
||||
sheet: MaterialSheetId,
|
||||
): Promise<void> {
|
||||
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
|
||||
let payload: Payload;
|
||||
let loaded: Awaited<ReturnType<typeof loadMaterials>>;
|
||||
try {
|
||||
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`);
|
||||
loaded = await loadMaterials(projectId);
|
||||
} catch (error) {
|
||||
host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`));
|
||||
return;
|
||||
}
|
||||
const draft = readState<MaterialOverrides>("b08-materials", projectId);
|
||||
let overrides: MaterialOverrides = draft ?? payload.overrides;
|
||||
const calc = (): MaterialSheets =>
|
||||
computeMaterialSheets(
|
||||
payload.source,
|
||||
overrides,
|
||||
liveSurcharge(projectId, payload.surcharge_pct),
|
||||
);
|
||||
let { payload, overrides } = loaded;
|
||||
const calc = (): MaterialSheets => loaded.calc(payload, overrides);
|
||||
let sheets = calc();
|
||||
|
||||
const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() });
|
||||
@@ -356,15 +159,9 @@ export async function renderMaterialSheet(
|
||||
if (sheet === "concrete") notes.append(concreteNote);
|
||||
toolbar.append(save);
|
||||
|
||||
let built = BUILD[sheet](sheets, overrides);
|
||||
let built = materialTable(sheet, sheets, overrides);
|
||||
const table = createDataTable({
|
||||
columns: built.columns,
|
||||
rows: built.rows,
|
||||
total: built.total,
|
||||
frozen: built.frozen,
|
||||
mergeSame: built.mergeSame,
|
||||
empty: "",
|
||||
caption: BASIS,
|
||||
...materialOptions(built),
|
||||
fillViewport: true,
|
||||
editable: (i, key) => built.editable(i, key),
|
||||
editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침
|
||||
@@ -388,7 +185,7 @@ export async function renderMaterialSheet(
|
||||
const excel = footNote("");
|
||||
excel.dataset.b08MaterialExcel = "";
|
||||
const redraw = (): void => {
|
||||
built = BUILD[sheet](sheets, overrides);
|
||||
built = materialTable(sheet, sheets, overrides);
|
||||
table.setRows(built.rows, built.total);
|
||||
excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets);
|
||||
concreteNote.textContent = concreteWhy(sheets, payload.source);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] ·
|
||||
* [단가연결 풀기] — 창 = 연결 없음 → 단가연결 목록 → 옵션 값 창(276 · 옛 길) · 연결 있음 →
|
||||
* 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · B09 운반공 줄이 받음
|
||||
* 표 열 · 줄 짜기 = `B08_Quantity_Milestone_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B08_Quantity_UI_Milestone.css";
|
||||
@@ -21,7 +22,8 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base";
|
||||
import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal";
|
||||
import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link";
|
||||
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import type { MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { DEST_TEXT, milestoneColumns, milestoneTable } from "./B08_Quantity_Milestone_Table";
|
||||
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
|
||||
import {
|
||||
liveMilestone,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
siteInfo,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
import {
|
||||
DEST_TEXT,
|
||||
editRoute,
|
||||
pickAtCell,
|
||||
routeView,
|
||||
@@ -39,9 +40,6 @@ import {
|
||||
type MilestoneView,
|
||||
} from "./B08_Quantity_UI_Milestone_Route";
|
||||
|
||||
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
|
||||
const ROAD = ["도로 구분 (km)"];
|
||||
const SUM = ["계 (km)"];
|
||||
/** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */
|
||||
const LAMP = "b08-milestone__lamp";
|
||||
|
||||
@@ -55,32 +53,6 @@ export function refText(ref: Record<string, unknown> | null | undefined): string
|
||||
return `단가연결 — ${what.trim()}`;
|
||||
}
|
||||
|
||||
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
|
||||
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
|
||||
export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] {
|
||||
const roads: DataColumn[] = withBase
|
||||
? [
|
||||
{ key: "s2", label: "2차포장", group: ROAD, ...KM },
|
||||
{ key: "s1", label: "1차포장", group: ROAD, ...KM },
|
||||
...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []),
|
||||
{ key: "un", label: "비포장", group: ROAD, ...KM },
|
||||
{ key: "paved", label: "포장", group: SUM, ...KM },
|
||||
{ key: "unpaved", label: "비포장", group: SUM, ...KM },
|
||||
]
|
||||
: [
|
||||
{ key: "paved", label: "포장", group: ROAD, ...KM },
|
||||
{ key: "un", label: "비포장", group: ROAD, ...KM },
|
||||
];
|
||||
return [
|
||||
{ key: "item", label: "항목", width: 134 },
|
||||
{ key: "company", label: "거래처 이름", width: 120 },
|
||||
{ key: "dest", label: "목적지", width: 72 },
|
||||
...roads,
|
||||
{ key: "total", label: "합계", group: SUM, ...KM, width: 108 },
|
||||
{ key: "note", label: "비고", width: 160 },
|
||||
];
|
||||
}
|
||||
|
||||
/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */
|
||||
export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}): string {
|
||||
const data = liveMilestone(projectId);
|
||||
@@ -180,17 +152,11 @@ function tableView(projectId: string): MilestoneView {
|
||||
});
|
||||
const refresh = (): void => {
|
||||
const data = liveMilestone(projectId);
|
||||
const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null);
|
||||
const { options, rows, mode: next } = milestoneTable(data, siteInfo()?.mid_default_m ?? null);
|
||||
// 기준점 생김 · 없앰 = 열 바뀜 → 표 새로
|
||||
const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`;
|
||||
if (next !== mode) {
|
||||
mode = next;
|
||||
const fresh = makeTable(
|
||||
milestoneColumns(
|
||||
data.bases.length > 0,
|
||||
rows.some((r) => r.pv > 0),
|
||||
),
|
||||
);
|
||||
const fresh = makeTable(options.columns);
|
||||
table.root.replaceWith(fresh.root);
|
||||
table = fresh;
|
||||
}
|
||||
@@ -201,7 +167,7 @@ function tableView(projectId: string): MilestoneView {
|
||||
L2: unpaved,
|
||||
}));
|
||||
none.hidden = rows.length > 0;
|
||||
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
|
||||
table.setRows(options.rows.map((r) => ({ ...r, dest: `${r.dest} ▾` })));
|
||||
diagram.refresh();
|
||||
};
|
||||
refresh();
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
type MapContextMenuItem,
|
||||
} from "@ui/ui_template_context_menu";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout";
|
||||
import { DEST_TEXT } from "./B08_Quantity_Milestone_Table";
|
||||
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
||||
import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place";
|
||||
import {
|
||||
@@ -54,8 +55,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void {
|
||||
menu.open(box.left, box.bottom, items);
|
||||
}
|
||||
|
||||
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
|
||||
|
||||
/** 그 경로를 고쳐 캐시에 */
|
||||
export function editRoute(
|
||||
projectId: string,
|
||||
|
||||
@@ -16,50 +16,28 @@
|
||||
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
|
||||
* 미연결 시설 = 모달 맨 위 「조합 연결」 → 바로 저장 → 탭 몸통을 새로 그림(154-2)
|
||||
* 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122)
|
||||
* 자료 읽기 · 표 짜기 = `B08_Quantity_Summary_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createCard, showToast } from "@ui/ui_template_elements";
|
||||
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { combosAt, unlinkedAt } from "./B08_Quantity_Summary_Data";
|
||||
import {
|
||||
combosAt,
|
||||
comboId,
|
||||
linkedOnly,
|
||||
pipeOwnerId,
|
||||
summaryColumns,
|
||||
summaryStations,
|
||||
type ComboColumn,
|
||||
type ComboLabel,
|
||||
type LinkedSnapshot,
|
||||
type PlacedItem,
|
||||
type UnlinkedFacility,
|
||||
unassignedText,
|
||||
unlinkedAt,
|
||||
widthOf,
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
|
||||
LAMP_CLASS,
|
||||
loadSummary,
|
||||
summaryTable,
|
||||
type SummaryView,
|
||||
} from "./B08_Quantity_Summary_Table";
|
||||
import type { SheetsRequest } from "./B08_Quantity_UI_Drawing";
|
||||
import { footNote } from "./B08_Quantity_UI_Foot";
|
||||
import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
import { onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */
|
||||
export async function stationInterval(projectId: string): Promise<number> {
|
||||
const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId));
|
||||
return latest.route_params?.station_interval_m || 20;
|
||||
}
|
||||
|
||||
function note(text: string): HTMLElement {
|
||||
const node = document.createElement("p");
|
||||
node.className = "ui-general-block__empty";
|
||||
@@ -67,128 +45,14 @@ function note(text: string): HTMLElement {
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
|
||||
interface PipeColumn {
|
||||
id: string;
|
||||
group: string[];
|
||||
label: string;
|
||||
combos: string[];
|
||||
}
|
||||
interface PipeSummary {
|
||||
columns: PipeColumn[];
|
||||
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
|
||||
total: Record<string, number>;
|
||||
notes: string[];
|
||||
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
|
||||
types: string[];
|
||||
one_length_m: number | null;
|
||||
pipes: PipeLength[];
|
||||
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
/** 미연결 시설(154-1) */
|
||||
unlinked: UnlinkedFacility[];
|
||||
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
|
||||
facilities: UnlinkedFacility[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const PIPE_TOP = "관 공";
|
||||
/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */
|
||||
const LAMP_CLASS = "b08-summary__lamp";
|
||||
/** 모두 연결 = 같은 램프 초록(247-1) */
|
||||
const LINKED_CLASS = `${LAMP_CLASS} is-linked`;
|
||||
const LAMP_W = 14;
|
||||
const stationKey = (station: number): number => Math.round(station * 100);
|
||||
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
|
||||
const placesOf = (values: number[]): number =>
|
||||
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
|
||||
|
||||
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */
|
||||
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
|
||||
const top =
|
||||
summary.columns
|
||||
.filter((column) => column.group[0] === PIPE_TOP)
|
||||
.flatMap((column) => column.combos)
|
||||
.map(comboPath)
|
||||
.find((path) => !!path) ?? PIPE_TOP;
|
||||
return summary.columns.map((column) => {
|
||||
const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group;
|
||||
const values = [
|
||||
...summary.rows.map((row) => row.values[column.id]),
|
||||
summary.total[column.id],
|
||||
].filter((value): value is number => typeof value === "number");
|
||||
const decimals = placesOf(values);
|
||||
const texts = values.map((value) =>
|
||||
value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
key: column.id,
|
||||
label: column.label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals,
|
||||
title: [...group, column.label].join(" › "),
|
||||
width: widthOf(column.label, ...texts),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildTable(
|
||||
stations: number[],
|
||||
columns: ComboColumn[],
|
||||
pipe: PipeSummary | null,
|
||||
comboPath: (id: string) => string | null,
|
||||
interval: number,
|
||||
unlinked: readonly UnlinkedFacility[],
|
||||
view: SummaryView,
|
||||
onRowClick: (station: number, label: string) => void,
|
||||
): HTMLElement {
|
||||
const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values]));
|
||||
const total: DataRow | null = pipe?.columns.length
|
||||
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
|
||||
: null;
|
||||
const labels = stations.map((station) => stationLabel(station, interval));
|
||||
const stationHead = L("B08_Quantity_Summary_Station");
|
||||
// 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2)
|
||||
const missing = stations.map((station) =>
|
||||
unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")),
|
||||
);
|
||||
// 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프
|
||||
const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0);
|
||||
const { options, labels, missing } = summaryTable(view);
|
||||
const table = createDataTable({
|
||||
columns: [
|
||||
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W },
|
||||
...columns.map((column) => ({
|
||||
key: column.id,
|
||||
label: column.name,
|
||||
group: [column.group || L("B08_Quantity_Summary_MissingGroup")],
|
||||
title: summaryTooltip(column.item),
|
||||
className: column.missing ? "is-missing" : undefined,
|
||||
width: widthOf(column.name),
|
||||
})),
|
||||
...(pipe ? pipeColumns(pipe, comboPath) : []),
|
||||
],
|
||||
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
|
||||
rows: stations.map((station, i) => ({
|
||||
station: labels[i],
|
||||
...values.get(stationKey(station)),
|
||||
})),
|
||||
total,
|
||||
onRowClick: (i) => onRowClick(stations[i], labels[i]),
|
||||
cellClass: (i, key) =>
|
||||
key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null,
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
caption: L("B08_Quantity_Tab_StructureSummary"),
|
||||
...options,
|
||||
onRowClick: (i) => onRowClick(view.stations[i], labels[i]),
|
||||
fillViewport: true,
|
||||
});
|
||||
table.root.dataset.b08Summary = "table";
|
||||
@@ -220,59 +84,17 @@ async function openSheets(request: SheetsRequest): Promise<void> {
|
||||
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
|
||||
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
|
||||
try {
|
||||
const [structures, pipes, interval, pipe] = await Promise.all([
|
||||
fetchStructures(projectId),
|
||||
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
|
||||
fetchDetailPipePoints(projectId).then(
|
||||
(response) => response.pipe_points,
|
||||
() => [],
|
||||
),
|
||||
stationInterval(projectId).catch(() => 20),
|
||||
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
|
||||
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
|
||||
]);
|
||||
const saved = typeof pipe === "string" ? null : pipe;
|
||||
// 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1)
|
||||
const linked = saved?.linked ?? {};
|
||||
const paths = new Map(
|
||||
Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const),
|
||||
);
|
||||
const describe = (item: SummaryItem): ComboLabel | null => {
|
||||
const [group = "", ...rest] = paths.get(comboId(item)) ?? [];
|
||||
return group ? { group, name: rest.join(" ") || group } : null;
|
||||
};
|
||||
const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null;
|
||||
// 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1)
|
||||
const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)];
|
||||
const placed = linkedOnly(
|
||||
pipes.map((point) => ({
|
||||
...point,
|
||||
structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m),
|
||||
})),
|
||||
linked,
|
||||
);
|
||||
const mine = linkedOnly(structures.structures, linked);
|
||||
const things: PlacedItem[] = [...mine, ...placed];
|
||||
const unlinked = saved?.unlinked ?? [];
|
||||
const stations = summaryStations([...things, ...unlinked]);
|
||||
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
const served = new Set(saved?.types ?? []);
|
||||
const others = [
|
||||
...mine.filter((item) => !served.has(item.type_id)),
|
||||
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
|
||||
];
|
||||
const columns = summaryColumns(others, describe);
|
||||
const live = (): PipeSummary | null =>
|
||||
saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m));
|
||||
const summary = live();
|
||||
const view = await loadSummary(projectId);
|
||||
const { stations, things, linked, describe, saved } = view;
|
||||
const summary = view.live();
|
||||
const parts: HTMLElement[] = [];
|
||||
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
|
||||
if (view.error) parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${view.error}`));
|
||||
if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄
|
||||
if (!columns.length && !summary?.columns.length)
|
||||
if (!view.columns.length && !summary?.columns.length)
|
||||
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
const draw = (): HTMLElement =>
|
||||
stations.length
|
||||
? buildTable(stations, columns, live(), comboPath, interval, unlinked, (station, label) =>
|
||||
? buildTable(view, (station, label) =>
|
||||
openSheets({
|
||||
projectId,
|
||||
station: label,
|
||||
|
||||
@@ -88,7 +88,9 @@ def test_연결값으로_판정이_나옴() -> None:
|
||||
|
||||
def test_저장된_측점_null_이_연결값을_덮지_않음() -> None:
|
||||
"""화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜."""
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity" / "B08_Quantity_Hydro_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "v != null || !(k in linkedOf(station))" in ui
|
||||
assert "...station.linked," in ui
|
||||
|
||||
|
||||
@@ -270,7 +270,9 @@ def test_열_폭_맨_아래_단_글만_44_100():
|
||||
|
||||
|
||||
def test_화면_조합_열_빼기_폭_머리_줄바꿈():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "served.has(point.facility" in ui and "served.has(item.type_id)" in ui
|
||||
assert "widthOf(column.label, ...texts)" in ui and "widthOf(column.name)" in ui
|
||||
assert "widthOf(stationHead, ...labels)" in ui
|
||||
|
||||
@@ -225,7 +225,9 @@ def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch):
|
||||
def test_화면_연결():
|
||||
page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
|
||||
assert "renderMaterialSheet(body, projectId, picked)" in page
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "computeMaterialSheets(" in ui # 조작 중 = 짝 계산
|
||||
assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음
|
||||
assert "일위대가 재료와 별개" in ui
|
||||
|
||||
@@ -169,10 +169,14 @@ def test_좌측_패널_컨테이너_셋과_저장_줄():
|
||||
|
||||
|
||||
def test_표가_옵션_바뀜에_바로_다시_셈():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui
|
||||
assert "onOptionsChange(host," in ui
|
||||
earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8")
|
||||
earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Earthwork_Tables.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "splitRockSummary(summary, table.totals, soft)" in earth
|
||||
assert "onOptionsChange(host," in earth
|
||||
assert (
|
||||
|
||||
@@ -123,7 +123,9 @@ def test_범위_밖_모르는_칸은_거절():
|
||||
|
||||
|
||||
def test_화면_연결():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "liveSurcharge(projectId, payload.surcharge_pct)" in ui
|
||||
assert "onOptionsChange(host," in ui
|
||||
assert 'key === "surcharge" && !rows[keep[i]].panel' in ui and "할증 = 좌측 패널" in ui
|
||||
|
||||
@@ -184,7 +184,9 @@ def test_미연결_시설_칸_전부_연결_여부(root):
|
||||
|
||||
|
||||
def test_화면_조합_이름_구조물도는_사본에서():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "loadPickerRows" not in ui # 층 목록 GET 안 씀
|
||||
assert "linkedOnly(" in ui
|
||||
assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||
SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_자재_셋_값_없는_줄_숨김():
|
||||
|
||||
@@ -157,5 +157,7 @@ def test_화면_초안이면_막고_고르기_모달_다시_그림():
|
||||
assert ".link(slot.copy, facility.facility, item)" in ui
|
||||
api = (ROOT / "ui_template/ui_template_item_picker_project.ts").read_text(encoding="utf-8")
|
||||
assert "/link`" in api
|
||||
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "host.replaceWith(fresh)" in page and "unlinkedAt(unlinked, station)" in page
|
||||
|
||||
@@ -60,7 +60,9 @@ def test_빈_조합_칸_글():
|
||||
|
||||
|
||||
def test_화면_연결():
|
||||
summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert (
|
||||
'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary
|
||||
) # 칸 이름 = 모달과 같은 규칙
|
||||
|
||||
@@ -11,7 +11,9 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
LINK = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8")
|
||||
DRAWING = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8")
|
||||
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
LOCALE = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ def test_한_칸만_연결_때_조합_하나():
|
||||
|
||||
|
||||
def test_화면_연결():
|
||||
summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "...(saved?.facilities ?? []).map((f) => f.id)" in summary
|
||||
assert "pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m)" in summary
|
||||
|
||||
@@ -13,7 +13,9 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS = ROOT / "config" / "node_modules" / "typescript"
|
||||
UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
PRELUDE = f"""
|
||||
const fs = require("fs");
|
||||
|
||||
@@ -108,10 +108,10 @@ def test_level_보기_전용_들여쓰기():
|
||||
|
||||
#: 적용 표 — [파일, 들어 있어야 할 글]
|
||||
APPLIED = [
|
||||
("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"),
|
||||
("B08_Quantity/B08_Quantity_Earthwork_Tables.ts", 'mergeSame: ["group", "spec"]'),
|
||||
("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["name"]'),
|
||||
("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["group"]'),
|
||||
("B08_Quantity/B08_Quantity_Material_Tables.ts", "mergeSame: built.mergeSame"),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'),
|
||||
|
||||
@@ -138,7 +138,9 @@ def test_B08_표_아래_안내는_footNote만():
|
||||
summary = _read(B08 / "B08_Quantity_UI_Summary.ts")
|
||||
assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임
|
||||
sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts")
|
||||
assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음
|
||||
assert (
|
||||
"tableCard(haulNote(table), grid.root, averages.map(note))" in sheets
|
||||
) # 제작 설명 줄 없음
|
||||
|
||||
|
||||
# ── 창 끝까지 셈 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,7 +120,7 @@ console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D
|
||||
|
||||
def test_표_열_기준점_따라():
|
||||
got = _node(
|
||||
[B08 + "UI_Milestone.ts"],
|
||||
[B08 + "Milestone_Table.ts"],
|
||||
"console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>"
|
||||
" milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));",
|
||||
)
|
||||
|
||||
@@ -166,7 +166,10 @@ def test_메인_탭_하위_탭_추가_삭제():
|
||||
assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui
|
||||
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
|
||||
assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal
|
||||
assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui
|
||||
assert "editOnDouble: true" in ui and "milestoneTable(data, siteInfo()" in ui
|
||||
assert "computeMilestone(data, midDefaultM)" in _read(
|
||||
"B08_Quantity/B08_Quantity_Milestone_Table.ts"
|
||||
)
|
||||
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
|
||||
assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route
|
||||
# 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음
|
||||
|
||||
@@ -136,6 +136,7 @@ def test_화면_글_거래처():
|
||||
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
|
||||
assert 'label: "거래처 이름"' in modal
|
||||
page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
||||
assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page
|
||||
table = _read("B08_Quantity/B08_Quantity_Milestone_Table.ts")
|
||||
assert '{ key: "company", label: "거래처 이름"' in table and "`거래처 ${" in page
|
||||
for name in UI:
|
||||
assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700
|
||||
|
||||
@@ -94,7 +94,9 @@ def test_측점_조합들():
|
||||
|
||||
def test_집계표는_공용_표():
|
||||
# PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님)
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui
|
||||
assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui
|
||||
assert 'document.createElement("table")' not in ui
|
||||
|
||||
@@ -209,7 +209,9 @@ def test_토공집계표_식() -> None:
|
||||
|
||||
def test_토적표_머리_글자는_사용자_견본대로():
|
||||
# 「[절토] <토사> · <연암> · 측구파기 <토사> · <암>」 — 값 · 계산은 그대로
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8")
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + (
|
||||
ROOT / "B08_Quantity" / "B08_Quantity_Earthwork_Tables.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
for head in (
|
||||
'["절토", "연암"]',
|
||||
'["절토", "경암"]',
|
||||
|
||||
@@ -249,7 +249,9 @@ def test_라우터_등록():
|
||||
def test_화면이_서버_칸을_그림():
|
||||
"""98-4 — 화면은 서버 배관 열 · 칸 · 계만 그림(계산 없음) · 배관 조합 열은 서버 열로 바뀜."""
|
||||
base = Path(__file__).resolve().parents[2]
|
||||
ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + (
|
||||
base / "B08_Quantity/B08_Quantity_Summary_Table.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui
|
||||
# 배관 · 횡단배수 조합 열 대신 서버 열(120 — 서버가 펼친 종류 `types` 를 뺌)
|
||||
assert '!served.has(point.facility ?? "pipe")' in ui
|
||||
|
||||
Reference in New Issue
Block a user