From c3af85113cb1cb86169cafc007bfa76de7300a66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:46:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B08):=20=ED=83=AD=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=C2=B7=20=ED=86=A0=EA=B3=B5=20=C2=B7=20=EC=88=98?= =?UTF-8?q?=EB=A6=AC=20+=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=EB=84=B7=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=ED=83=AD=20=EC=A7=80=EC=9B=80=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=20=ED=83=AD=20=EC=9C=84=20=EA=B0=84=EA=B2=A9=208px(95?= =?UTF-8?q?-1=20=C2=B7=2095-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물도 모달(집계표 행 누름 · 그 측점 조합 전부 · 복사본 먼저) · 공용 표 onRowClick opt-in(95-3) · 수리 상세 폭 제한 풂 · 카드 높이 화면 따라 · @container(95-4). 화면 검증 전. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- B08_Quantity/B08_Quantity_Summary_Data.ts | 33 +++ B08_Quantity/B08_Quantity_UI_Drawing.css | 41 ++- B08_Quantity/B08_Quantity_UI_Drawing.ts | 249 ++++++++++-------- B08_Quantity/B08_Quantity_UI_Hydro.css | 21 +- B08_Quantity/B08_Quantity_UI_Page.css | 2 +- B08_Quantity/B08_Quantity_UI_Page.ts | 75 ++++-- B08_Quantity/B08_Quantity_UI_Summary.ts | 45 +++- resources/tester/helper_ui_data_table.cjs | 6 + .../tester/test_61_1_b08_summary_table.py | 22 +- .../tester/test_61_2_b08_structure_sheets.py | 27 +- resources/tester/test_ui_data_table.py | 9 + ui_template/ui_template_data_table.css | 4 + ui_template/ui_template_data_table.ts | 15 +- ui_template/ui_template_locale_b2.ts | 1 - ui_template/ui_template_locale_b4.ts | 11 + 15 files changed, 409 insertions(+), 152 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index 24c0df084..370c5c307 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -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(); + 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]> = []; diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.css b/B08_Quantity/B08_Quantity_UI_Drawing.css index 716d0fb64..4d0dfee05 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.css +++ b/B08_Quantity/B08_Quantity_UI_Drawing.css @@ -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); } } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index d4c70a2e4..b3356214a 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -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 }> { - 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[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; + describe: (item: SummaryItem) => ComboLabel | null; } -/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ -export async function renderStructureSheets(host: HTMLElement, projectId: string): Promise { - 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 => + 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}`)); + } + })(); + }, + }); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css index 77a3f09ec..eb22af05b 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.css +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -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 - 300px); +} + +.b08-hydro__card .ui-card__body { + min-height: 0; + overflow: auto; +} + .b08-hydro__card .ui-card__title { margin-bottom: 0; } diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 4cf1f57ca..a74e7d678 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -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)과 같은 값 */ } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index e783faee5..c2621bab7 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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"), diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 8fae5e08e..b1d954caa 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -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, + describe: (item: SummaryItem) => ComboLabel | null, +): Promise { + 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 { 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[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); diff --git a/resources/tester/helper_ui_data_table.cjs b/resources/tester/helper_ui_data_table.cjs index 1b7ddf2db..3dad134c2 100644 --- a/resources/tester/helper_ui_data_table.cjs +++ b/resources/tester/helper_ui_data_table.cjs @@ -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: "" }], rows: [{ x: "a&<" }] }), noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes(""), }), diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index fd1379584..bfba8a6f8 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -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 diff --git a/resources/tester/test_61_2_b08_structure_sheets.py b/resources/tester/test_61_2_b08_structure_sheets.py index aa077e161..05333c28e 100644 --- a/resources/tester/test_61_2_b08_structure_sheets.py +++ b/resources/tester/test_61_2_b08_structure_sheets.py @@ -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 diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index cbbecc7c5..56fe2c74a 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -92,3 +92,12 @@ def test_빈_칸_글_풍선_꾸밈_옵션(r): assert '' in o # empty="" — 「-」 대신 빈칸 assert '메쌓기' in o assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점' in o + + +def test_줄_누름_옵션일_때만_data_i(r): + # 95-3 — onRowClick 을 주면 본문 줄에만 data-i · is-clickable(합계 줄 제외) · 안 주면 글 그대로 + pick = r["pick"] + assert '' in pick + assert '' in pick + assert pick.count("data-i=") == 2 + assert "data-i" not in r["opts"] and "is-clickable" not in r["foot"] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 251a5aa0f..c915b2519 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -133,3 +133,7 @@ color: var(--color-text); font-weight: var(--font-weight-bold); } + +.ui-data-table__table tbody tr.is-clickable { + cursor: pointer; +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index a20737edb..a77ef0e5d 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -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 += `${esc(txt)}`; } - return alt ? `${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}` : `${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 ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.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("tr[data-i]"); + if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i)); + }); draw(true); return { root, diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ee0678e37..8c553b080 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -615,7 +615,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"], diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 1a0698a4b..010dbce23 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -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: [ "구조물도를 불러오지 못했습니다", From a632d4ef6b13622ec097d00dd5931b1d65cec980 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:53:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(B08):=20=EC=88=98=EB=A6=AC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=86=92=EC=9D=B4=20100vh=20=E2=88=92=20240px=20?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=9E=EC=B6=A4(95-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- B08_Quantity/B08_Quantity_UI_Hydro.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css index eb22af05b..0619750ab 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.css +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -43,7 +43,7 @@ .b08-hydro__card { display: flex; flex-direction: column; - max-height: calc(100vh - 300px); + max-height: calc(100vh - 240px); } .b08-hydro__card .ui-card__body {