Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-08 13:55:29 +09:00
15 changed files with 409 additions and 152 deletions
+33
View File
@@ -17,6 +17,8 @@ import {
/** 구조물 · 관 지점 한 건 — 필요한 칸만. 구간형은 기준점(`chainage_m`) · 없으면 시점. */
export interface PlacedItem {
/** 복사본 폴더를 찾는 구조물 id(관 지점은 `pipe@측점`) — 없으면 복사본 없이 원본 */
structure_id?: string | null;
chainage_m?: number | null;
start_m?: number | null;
summary_item?: unknown;
@@ -82,6 +84,37 @@ export function summaryColumns(
);
}
/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */
export interface StationCombo {
id: string;
item: SummaryItem;
structureId: string | null;
}
/** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */
export function combosAt(things: readonly PlacedItem[], station: number): StationCombo[] {
const seen = new Set<string>();
const found: StationCombo[] = [];
for (const thing of things) {
const at = stationOf(thing);
if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue;
const own = readSummaryItem(thing.summary_item);
for (const item of [
...(own ? [own] : []),
...Object.values(readGroupItems(thing.group_items)),
]) {
const structureId = thing.structure_id ?? null;
const key = `${structureId}
${comboId(item)}`;
if (!seen.has(key)) {
seen.add(key);
found.push({ id: comboId(item), item, structureId });
}
}
}
return found;
}
/** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */
export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> {
const spans: Array<[string, number]> = [];
+36 -5
View File
@@ -1,17 +1,48 @@
/* B08 2탭 구조물도(PLAN 61-2) — 보조 탭 · 카드 둘(도면 · 면적산출). 색은 테마 변수만. */
/* B08 구조물도 모달(PLAN 95-3) — 조합마다 카드 둘(도면 · 면적산출). 색은 테마 변수만. */
/* M02 에 없는 조합 — 빨간 글은 비어 있는 단추만(고른 단추 바탕 위에선 안 읽힘 · 아래 안내 글이 알림) */
.b08-drawing__tabs .is-missing:not(.ui-btn--filled) {
/* 큰 창 — 공용 모달 css 는 그대로 두고 자기 class 로 */
.ui-modal.b08-sheets {
width: min(1320px, 96vw);
--modal-max: 94vh;
}
.b08-sheets .ui-modal__body {
gap: var(--spacing-24);
}
.b08-sheets__combo {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b08-sheets__title {
display: flex;
align-items: baseline;
gap: var(--spacing-8);
margin: 0;
font-size: var(--text-body);
color: var(--color-text);
}
.b08-sheets__from {
font-size: var(--text-caption);
font-weight: var(--font-weight-regular);
color: var(--color-text-secondary);
}
/* M02 에 없는 조합 안내 — 빨간 글 */
.b08-sheets__combo > .ui-general-block__empty {
color: var(--color-danger);
}
/* M02 카드 칸은 셋(도면 · 일위대가 · 면적산출) — 여기는 둘 · 도면을 넓게 */
.b08-drawing__cards .m02-struct {
.b08-sheets .m02-struct {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
}
@media (max-width: 960px) {
.b08-drawing__cards .m02-struct {
.b08-sheets .m02-struct {
grid-template-columns: minmax(0, 1fr);
}
}
+146 -103
View File
@@ -1,29 +1,30 @@
/* =============================================================================
* B08_Quantity_UI_Drawing.ts
* 2탭 구조물도(PLAN 61-2) — 집계표 열(저장된 구조물 조합)마다 보조 탭 · 한 장 = 그 조합의
* M02 도면(도각 포함 · 보기 전용 CAD) + 면적산출 — M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로.
* 구조물 집계표 행 → 구조물도 모달(PLAN 95-3) — 그 측점에 놓인 조합 전부를 세로로 쌓음.
* 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD ·
* 여러 장이면 장 탭) + 면적산출.
*
* 조합 목록 · 차례 = 집계표와 같음(`B08_Quantity_Summary_Data`)
* 읽기만 — 조합 원본 층(`readTemplate`)을 읽고 카드는 [저장] · [수정] 없음 · B05/B06 자료도 읽기만
* 카드 · CAD · 스프레드시트는 이 탭을 열 때만 `import()`
* 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만
* 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`)
* 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리
* ========================================================================== */
import "./B08_Quantity_UI_Drawing.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
import { layerOf, pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { summaryTooltip } from "../common_util/common_util_summary_item";
import { openModal } from "@ui/ui_template_modal";
import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data";
import {
comboId,
summaryColumns,
type ComboColumn,
type ComboLabel,
} from "./B08_Quantity_Summary_Data";
PART_OF,
projectItems,
sameItem,
structureFolder,
type ProjectItemInfo,
} from "@ui/ui_template_item_picker_project";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -36,94 +37,136 @@ function note(text: string): HTMLElement {
return node;
}
/** 집계표와 같은 열 + 열마다 M02 줄(없으면 M02 에 없는 조합). */
async function loadColumns(
projectId: string,
): Promise<{ columns: ComboColumn[]; rows: Map<string, PickerRow> }> {
const [structures, pickerRows, pipes] = await Promise.all([
fetchStructures(projectId),
loadPickerRows(),
// 배수 분석 전이면 관이 없음 — 구조물만으로
fetchDetailPipePoints(projectId).then(
(response) => response.pipe_points,
() => [],
),
]);
const rows = new Map(pickerRows.map((row) => [rowId(row), row]));
const describe = (item: Parameters<typeof comboId>[0]): ComboLabel | null => {
const row = rows.get(comboId(item));
if (!row) return null;
const [group = "", ...rest] = pathOf(row);
return { group, name: rest.join(" ") || group || row.key };
};
return { columns: summaryColumns([...structures.structures, ...pipes], describe), rows };
export interface SheetsRequest {
projectId: string;
/** 모달 제목에 붙는 측점 글 */
station: string;
combos: StationCombo[];
/** `comboId` → M02 줄 */
rows: Map<string, PickerRow>;
describe: (item: SummaryItem) => ComboLabel | null;
}
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderStructureSheets(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note(L("B08_Quantity_Drawing_Loading")));
try {
const [{ columns, rows }, { createItemCards }] = await Promise.all([
loadColumns(projectId),
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
]);
if (!host.isConnected) return; // 그새 다른 탭으로 옮김
if (!columns.length) return void host.replaceChildren(note(L("B08_Quantity_Summary_NoCombo")));
/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */
const loadCopies = (projectId: string): Promise<ProjectItemInfo[]> =>
projectItems(projectId)
.list()
.catch(() => []);
let row: PickerRow | null = null;
const source = (): ItemSource => {
const layer = row ? layerOf(row) : "system";
return {
layer,
projectId: null,
read: (part, column) => readTemplate(layer, part, column, null),
};
function sourceOf(
request: SheetsRequest,
combo: StationCombo,
row: PickerRow,
copies: ProjectItemInfo[],
): { source: ItemSource; copy: boolean } {
const own = combo.structureId
? copies.find((info) => info.폴더 === structureFolder(combo.structureId!))
: undefined;
if (own && sameItem(own, row)) {
const items = projectItems(request.projectId);
return {
copy: true,
source: {
layer: "project",
projectId: request.projectId,
read: async (part) => {
const doc = await items.read(own.폴더, PART_OF[part]);
if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy"));
return { 문서: doc };
},
},
};
const cards = createItemCards({
source,
editable: () => false,
onEdit: () => undefined,
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
});
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs b08-drawing__tabs";
tabs.setAttribute("role", "tablist");
const notice = document.createElement("div");
const sheet = document.createElement("div");
sheet.className = "b08-drawing__cards";
sheet.append(cards.root);
const select = (picked: ComboColumn): void => {
tabs.replaceChildren(
...columns.map((column) => {
const button = createButton({
label: column.group ? `${column.group} ${column.name}` : column.name,
variant: column === picked ? "filled" : "ghost",
size: "sm",
onClick: () => column !== picked && select(column),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(column === picked));
button.dataset.b08Combo = column.id;
button.title = summaryTooltip(column.item);
if (column.missing) button.classList.add("is-missing");
return button;
}),
);
row = rows.get(picked.id) ?? null;
if (!row?.열) {
notice.replaceChildren(note(L("B08_Quantity_Drawing_Missing")));
return void cards.load(null);
}
notice.replaceChildren(...(row.도번 ? [] : [note(L("B08_Quantity_Drawing_NoDrawing"))]));
void cards.load({ id: row.열, label: picked.name });
};
host.replaceChildren(tabs, notice, sheet);
select(columns[0]);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
host.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`));
}
const layer = layerOf(row);
return {
copy: false,
source: {
layer,
projectId: null,
read: (part, column) => readTemplate(layer, part, column, null),
},
};
}
/** 조합 한 칸 — 제목줄(이름 · 원천) + 안내 + 카드. */
function comboSection(
request: SheetsRequest,
combo: StationCombo,
copies: ProjectItemInfo[],
createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards,
handles: ItemCardsHandle[],
): HTMLElement {
const label = request.describe(combo.item);
const name = label
? [label.group, label.name].filter(Boolean).join(" ")
: combo.item.name || combo.item.key;
const row = request.rows.get(combo.id) ?? null;
const section = document.createElement("section");
section.className = "b08-sheets__combo";
section.dataset.b08Combo = combo.id;
const title = document.createElement("h4");
title.className = "b08-sheets__title";
title.textContent = name;
title.title = summaryTooltip(combo.item);
section.append(title);
if (!row?.열) {
section.append(note(L("B08_Quantity_Drawing_Missing")));
return section;
}
const picked = sourceOf(request, combo, row, copies);
const from = document.createElement("span");
from.className = "b08-sheets__from";
from.textContent = L(
picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster",
);
title.append(from);
if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
const cards = createItemCards({
source: () => picked.source,
editable: () => false,
onEdit: () => undefined,
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
});
handles.push(cards);
section.append(cards.root);
void cards.load({ id: row.열, label: name });
return section;
}
/** 모달을 엶 — 조합이 없으면 안내 한 줄 · 불러오다 실패하면 까닭 한 줄. */
export function openStructureSheets(request: SheetsRequest): void {
const handles: ItemCardsHandle[] = [];
let closed = false;
openModal({
title: `${request.station} ${L("B08_Quantity_Sheets_Title")}`,
closeLabel: L("Common_Btn_Close"),
dialogClass: "b08-sheets",
beforeClose: () => {
closed = true; // 아직 불러오는 중이면 뒤늦게 카드를 만들지 않음
handles.forEach((cards) => cards.destroy());
return true;
},
mount: (body) => {
if (!request.combos.length) return void body.append(note(L("B08_Quantity_Summary_NoCombo")));
body.append(note(L("B08_Quantity_Drawing_Loading")));
void (async () => {
try {
const [{ createItemCards }, copies] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
loadCopies(request.projectId),
]);
if (closed) return;
body.replaceChildren(
...request.combos.map((combo) =>
comboSection(request, combo, copies, createItemCards, handles),
),
);
} catch (error) {
if (closed) return;
const reason = error instanceof Error ? error.message : String(error);
body.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`));
}
})();
},
});
}
+14 -7
View File
@@ -2,12 +2,6 @@
색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만
· 굵게 + 붉은 계열은 중요 결과(.is-key)만. */
/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */
.b08-hydro__toolbar,
.b08-hydro__sheet {
max-width: 1000px;
}
.b08-hydro__toolbar {
display: flex;
align-items: flex-start;
@@ -27,6 +21,7 @@
}
.b08-hydro__sheet {
container-type: inline-size; /* 2열 ↔ 1열 = 창이 아닌 이 칸 폭 기준 */
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
@@ -38,12 +33,24 @@
gap: var(--spacing-16);
}
@media (max-width: 960px) {
@container (max-width: 800px) {
.b08-hydro__body {
grid-template-columns: minmax(0, 1fr);
}
}
/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100vh − N)) · 넘치면 카드 몸만 스크롤 */
.b08-hydro__card {
display: flex;
flex-direction: column;
max-height: calc(100vh - 240px);
}
.b08-hydro__card .ui-card__body {
min-height: 0;
overflow: auto;
}
.b08-hydro__card .ui-card__title {
margin-bottom: 0;
}
+1 -1
View File
@@ -3,7 +3,7 @@
.b08-quantity {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
gap: var(--spacing-8); /* 보조 탭 줄 위 = 아래(.b08-quantity__tabs padding-bottom) */
padding: var(--spacing-16) var(--spacing-24); /* M02 본문(.m02-main)과 같은 값 */
}
+55 -20
View File
@@ -2,9 +2,9 @@
* B08_Quantity_UI_Page.ts
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
*
* 탭 = 구조물 집계표 · 구조물도 · 토공 · 수리집수면적표 — 차례대로 만듦.
* 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1).
* 1탭(구조물 집계표 · 61-1) · 4탭(수리집수면적표 · 홍수량 계산서 62-2) 내용 · 나머지는 빈 탭.
* 탭 = 구조물 · 토공 · 수리집수면적표 (PLAN 95-1). 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1).
* 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 ·
* 자재수량집계표(셋은 아직 빈 탭) — 구조물도는 집계표 행을 눌러 여는 모달(95-3).
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
* ========================================================================== */
@@ -18,7 +18,6 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
import { renderHydro } from "./B08_Quantity_UI_Hydro";
import { renderStructureSheets } from "./B08_Quantity_UI_Drawing";
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
function L(key: keyof typeof ui_locales): string {
@@ -26,32 +25,68 @@ function L(key: keyof typeof ui_locales): string {
}
const TABS = [
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
{ id: "sheet", label: "B08_Quantity_Tab_StructureSheet" },
{ id: "structure", label: "B08_Quantity_Tab_Structure" },
{ id: "earthwork", label: "B08_Quantity_Tab_Earthwork" },
{ id: "hydrology", label: "B08_Quantity_Tab_Hydrology" },
] as const;
type TabId = (typeof TABS)[number]["id"];
/** 구조물 탭 보조 탭 — 첫째만 내용이 있음. */
const SHEETS = [
{ id: "summary", label: "B08_Quantity_Tab_StructureSummary" },
{ id: "material", label: "B08_Quantity_Tab_MaterialTotal" },
{ id: "concrete", label: "B08_Quantity_Tab_ConcreteMaterial" },
{ id: "quantity", label: "B08_Quantity_Tab_MaterialQuantity" },
] as const;
type SheetId = (typeof SHEETS)[number]["id"];
function pendingNote(): HTMLElement {
const pending = document.createElement("p");
pending.className = "ui-general-block__empty";
pending.textContent = L("B08_Quantity_Tab_Pending");
return pending;
}
/** 구조물 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */
function renderStructure(host: HTMLElement, projectId: string | null): void {
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs b08-structure__tabs";
tabs.setAttribute("role", "tablist");
const view = document.createElement("div");
const select = (picked: SheetId): void => {
tabs.replaceChildren(
...SHEETS.map(({ id, label }) => {
const button = createButton({
label: L(label),
variant: id === picked ? "filled" : "ghost",
size: "sm",
onClick: () => id !== picked && select(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === picked));
button.dataset.b08StructureSheet = id;
return button;
}),
);
const body = document.createElement("div");
view.replaceChildren(body);
if (picked === "summary" && projectId) void renderStructureSummary(body, projectId);
else body.append(pendingNote());
};
host.replaceChildren(tabs, view);
select("summary");
}
/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */
function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void {
const view = document.createElement("div");
host.replaceChildren(view);
if (tab === "summary" && projectId) {
void renderStructureSummary(view, projectId);
return;
}
if (tab === "hydrology" && projectId) {
void renderHydro(view, projectId);
return;
}
if (tab === "sheet" && projectId) return void renderStructureSheets(view, projectId);
if (tab === "structure") return renderStructure(view, projectId);
if (tab === "hydrology" && projectId) return void renderHydro(view, projectId);
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
const pending = document.createElement("p");
pending.className = "ui-general-block__empty";
pending.textContent = L("B08_Quantity_Tab_Pending");
view.append(pending);
view.append(pendingNote());
}
export function renderB08Quantity(root: HTMLElement): void {
@@ -84,7 +119,7 @@ export function renderB08Quantity(root: HTMLElement): void {
const body = document.createElement("div");
body.className = "b08-quantity";
body.append(tabBar, panel);
select("summary");
select("structure");
const layout = createWorkflowLayout({
title: L("B08_Quantity_Title"),
+39 -6
View File
@@ -5,23 +5,29 @@
*
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
import { pathOf, rowId } from "@ui/ui_template_item_picker_data";
import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
import { 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 } from "@ui/ui_template_data_table";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import { stationLabel } from "../common_util/common_util_station";
import { summaryTooltip } from "../common_util/common_util_summary_item";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import {
combosAt,
comboId,
summaryColumns,
summaryStations,
type ComboColumn,
type ComboLabel,
type PlacedItem,
type StationCombo,
} from "./B08_Quantity_Summary_Data";
function L(key: keyof typeof ui_locales): string {
@@ -44,7 +50,12 @@ function note(text: string): HTMLElement {
/** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */
const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24));
function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement {
function buildTable(
stations: number[],
columns: ComboColumn[],
interval: number,
onRowClick: (station: number, label: string) => void,
): HTMLElement {
const table = createDataTable({
columns: [
{ key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 },
@@ -59,6 +70,7 @@ function buildTable(stations: number[], columns: ComboColumn[], interval: number
],
// 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸
rows: stations.map((station) => ({ station: stationLabel(station, interval) })),
onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)),
frozen: 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
@@ -67,6 +79,22 @@ function buildTable(stations: number[], columns: ComboColumn[], interval: number
return table.root;
}
/** 행 누름 — 카드 · CAD 묶음은 이때 처음 받음. */
async function openSheets(
projectId: string,
station: string,
combos: StationCombo[],
rows: Map<string, PickerRow>,
describe: (item: SummaryItem) => ComboLabel | null,
): Promise<void> {
try {
const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing");
openStructureSheets({ projectId, station, combos, rows, describe });
} catch (error) {
showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error");
}
}
/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
@@ -82,20 +110,25 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
stationInterval(projectId).catch(() => 20),
]);
const m02 = new Map(pickerRows.map((row) => [rowId(row), row]));
const describe = (item: Parameters<typeof comboId>[0]): ComboLabel | null => {
const describe = (item: SummaryItem): ComboLabel | null => {
const row = m02.get(comboId(item));
if (!row) return null;
const [group = "", ...rest] = pathOf(row);
return { group, name: rest.join(" ") || group || row.key };
};
const things = [...structures.structures, ...pipes];
const things: PlacedItem[] = [
...structures.structures,
...pipes.map((pipe) => ({ ...pipe, structure_id: pipeStructureId(pipe.chainage_m) })),
];
const stations = summaryStations(things);
const columns = summaryColumns(things, describe);
const parts: HTMLElement[] = [];
if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo")));
parts.push(
stations.length
? buildTable(stations, columns, interval)
? buildTable(stations, columns, interval, (station, label) =>
openSheets(projectId, label, combosAt(things, station), m02, describe),
)
: note(L("B08_Quantity_Summary_NoStation")),
);
host.replaceChildren(...parts);
@@ -82,6 +82,12 @@ console.log(
frozen: 1,
empty: "",
}),
pick: m.renderDataTableHtml({
columns: [{ key: "x", label: "X" }],
rows: [{ x: 1 }, { x: 2 }],
total: { x: 3 },
onRowClick: () => undefined,
}),
escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "<b>" }], rows: [{ x: "a&<" }] }),
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
}),
@@ -72,9 +72,29 @@ def test_빈_목록():
assert out == [[], [], []]
def test_측점_조합들():
# 95-3 — 한 측점(1cm 안) 조합 전부 · 본체 → 그룹 차례 · 구조물 id 따라감 · 같은 구조물 같은 조합은 한 건
things = [
{"structure_id": "s1", "chainage_m": 40.004, "summary_item": _item("SS1"),
"group_items": {"g1": _item("SS3"), "g2": _item("SS1")}},
{"structure_id": "pipe@40.0", "chainage_m": 40.0, "summary_item": _item("SS1")},
{"structure_id": "s2", "chainage_m": 80.0, "summary_item": _item("SS2")},
] # fmt: skip
out = _run(
f"\nconst things = {json.dumps(things, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify([combosAt(things, 40).map((c) => [c.id, c.structureId]),"
" combosAt(things, 80).length, combosAt(things, 99).length]));"
)
assert out == [
[["system||SS1", "s1"], ["system||SS3", "s1"], ["system||SS1", "pipe@40.0"]],
1,
0,
]
def test_집계표는_공용_표():
# PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님)
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.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
assert "frozen: 1" in ui and 'empty: ""' in ui and "onRowClick" in ui
assert 'document.createElement("table")' not in ui
@@ -1,15 +1,28 @@
"""61-2 B08 2탭 구조물도 — 탭에 붙음 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키."""
"""61-2 · 95-3 B08 구조물도 모달 — 집계표 행에서 열림 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키."""
import re
from pathlib import Path
DRAWING = Path("B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
SUMMARY = Path("B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
PAGE = Path("B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
LOCALE = Path("ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
LOCALE_B2 = Path("ui_template/ui_template_locale_b2.ts").read_text(encoding="utf-8")
def test_sheet_tab_wired():
assert 'tab === "sheet"' in PAGE and "renderStructureSheets(view, projectId)" in PAGE
def test_sheet_tab_gone_modal_wired():
# 구조물도 탭 없음 · 집계표 행 누름이 모달을 늦게 불러 엶
assert '"sheet"' not in PAGE and "renderStructureSheets" not in PAGE
assert "B08_Quantity_Tab_StructureSheet" not in PAGE + LOCALE + LOCALE_B2
assert "onRowClick" in SUMMARY and 'import("./B08_Quantity_UI_Drawing")' in SUMMARY
assert "combosAt(things, station)" in SUMMARY
def test_modal_own_class_and_cleanup():
css = Path("B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8")
assert "min(1320px, 96vw)" in css and "--modal-max: 94vh" in css
assert "beforeClose" in DRAWING and "cards.destroy()" in DRAWING
assert 'dialogClass: "b08-sheets"' in DRAWING
def test_read_only():
@@ -19,10 +32,12 @@ def test_read_only():
assert "editable: () => false" in DRAWING
def test_same_columns_as_summary():
assert "summaryColumns(" in DRAWING and "./B08_Quantity_Summary_Data" in DRAWING
def test_copy_first_then_master():
# 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층
assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING
assert "readTemplate(layer, part, column, null)" in DRAWING
def test_locale_keys_exist():
for key in set(re.findall(r'L\("([A-Za-z0-9_]+)"\)', DRAWING)):
assert f"{key}:" in LOCALE or key.startswith("B08_Quantity_Summary_"), key
assert f"{key}:" in LOCALE or key.startswith(("B08_Quantity_Summary_", "Common_")), key
+9
View File
@@ -92,3 +92,12 @@ def test_빈_칸_글_풍선_꾸밈_옵션(r):
assert '<td class="is-empty"></td>' in o # empty="" — 「-」 대신 빈칸
assert '<th scope="col" rowspan="1" class="is-missing" title="풍선">메쌓기</th>' in o
assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점</th>' in o
def test_줄_누름_옵션일_때만_data_i(r):
# 95-3 — onRowClick 을 주면 본문 줄에만 data-i · is-clickable(합계 줄 제외) · 안 주면 글 그대로
pick = r["pick"]
assert '<tr class="is-clickable" data-i="0">' in pick
assert '<tr class="is-alt is-clickable" data-i="1">' in pick
assert pick.count("data-i=") == 2
assert "data-i" not in r["opts"] and "is-clickable" not in r["foot"]
+4
View File
@@ -133,3 +133,7 @@
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
.ui-data-table__table tbody tr.is-clickable {
cursor: pointer;
}
+13 -2
View File
@@ -43,6 +43,8 @@ export interface DataTableOptions {
caption?: string;
/** 빈 값 글(기본 「-」 · 사용자가 채울 칸은 "") */
empty?: string;
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
onRowClick?: (index: number) => void;
}
export interface DataTable {
@@ -112,6 +114,7 @@ function rowHtml(
r: DataRow,
alt = false,
blank = EMPTY,
index: number | null = null,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
@@ -126,7 +129,9 @@ function rowHtml(
(fmts[j] ? ' data-num=""' : "");
tr += `<td${attrs}>${esc(txt)}</td>`;
}
return alt ? `<tr class="is-alt">${tr}</tr>` : `<tr>${tr}</tr>`;
const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" ");
const data = index !== null ? ` data-i="${index}"` : "";
return cls || data ? `<tr${cls ? ` class="${cls}"` : ""}${data}>${tr}</tr>` : `<tr>${tr}</tr>`;
}
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
@@ -163,7 +168,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
let body = win ? pad(from * win.rowH, cols.length) : "";
const blank = o.empty ?? EMPTY;
for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank);
const pick = o.onRowClick !== undefined;
for (let i = from; i < to; i++)
body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank, pick ? i : null);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
@@ -199,6 +206,10 @@ export function createDataTable(opts: DataTableOptions): DataTable {
}
};
root.addEventListener("scroll", () => draw(false), { passive: true });
root.addEventListener("click", (event) => {
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i));
});
draw(true);
return {
root,
-1
View File
@@ -611,7 +611,6 @@ export const ui_locales_b2 = {
B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"],
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
B08_Quantity_Tab_StructureSummary: ["구조물 집계표", "Structure Summary"],
B08_Quantity_Tab_StructureSheet: ["구조물도", "Structure Sheets"],
B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"],
B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"],
B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"],
+11
View File
@@ -19,6 +19,17 @@ export const ui_locales_b4 = {
],
B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"],
B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."],
B08_Quantity_Tab_Structure: ["구조물", "Structure"],
B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"],
B08_Quantity_Tab_ConcreteMaterial: [
"콘크리트소요자재집계표",
"Concrete Material Requirement Table",
],
B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"],
B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"],
B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"],
B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"],
B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"],
B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"],
B08_Quantity_Drawing_Failed: [
"구조물도를 불러오지 못했습니다",