diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index d8e6626cb..e0bb13ba7 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -219,7 +219,7 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) " ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"]))) for k in lost ) - notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}") + notes.append(f"2공구 열 맞춤 없음 {len(lost)}종 — {listed}") return { "source": source, "overrides": overrides, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index f8c318187..ac64372d5 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -15,6 +15,7 @@ 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 { footNote } from "./B08_Quantity_UI_Foot"; import { stationInterval } from "./B08_Quantity_UI_Summary"; import { renderEarthworkSummary, @@ -216,17 +217,13 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); return; } - const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? ""; const flagged = table.rows.filter((row) => row.notes.length); const info = [ - note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 암 ` + - `${table.conversion_factors.ripping_rock?.compacted.toFixed(3) ?? ""}(연암 리핑 ` + - `${table.rock_c.ripping_rock} · 경암 발파 ${table.rock_c.blasting_rock} 비율 가중) · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + - `단위 m · ㎡ · ㎥`, + footNote( + `노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 단위 m · ㎡ · ㎥`, ), - ...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), + ...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), ]; const grid = createDataTable({ columns: COLUMNS, diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index e7acf989c..ee9e58c22 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -9,6 +9,7 @@ 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 { footNote } from "./B08_Quantity_UI_Foot"; const fmt = (value: number | null | undefined): string => value == null || !Number.isFinite(value) @@ -26,9 +27,8 @@ const SUMMARY_COLUMNS: DataColumn[] = [ /** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { - const info = note( - "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + - "연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)", + const info = footNote( + "연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움", ); const grid = createDataTable({ columns: SUMMARY_COLUMNS, @@ -50,7 +50,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) function haulNote(table: EarthworkTable): HTMLElement[] { return table.haul_saved ? [] - : [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; + : [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; } /** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ @@ -58,7 +58,7 @@ function haulSheet( host: HTMLElement, table: EarthworkTable, groups: Array<[string, HaulList]>, - info: string, + caption: string, ): void { const columns: DataColumn[] = [ { key: "no", label: "번호", width: 56 }, @@ -98,10 +98,10 @@ function haulSheet( rows, frozen: 1, total, - caption: info, + caption, fillViewport: true, }); - host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); + host.replaceChildren(tableCard(haulNote(table), grid.root, averages)); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { @@ -112,7 +112,7 @@ export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): vo ["덤프트럭", table.haul.dump_truck], ["불도우저", table.haul.dozer], ], - "유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", ); } @@ -121,6 +121,6 @@ export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { host, table, [["종방향무대", table.haul.free_haul]], - "유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + "종방향 무대운반거리 — 단위 ㎥ · m", ); } diff --git a/B08_Quantity/B08_Quantity_UI_Foot.ts b/B08_Quantity/B08_Quantity_UI_Foot.ts new file mode 100644 index 000000000..1376cc3ad --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Foot.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * B08_Quantity_UI_Foot.ts + * B08 표 아래 안내 · 경고 줄(PLAN 202-1 · 236-1) — 늘 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문. + * 한 줄만 남겨 창 끝까지 셈(199-1)에 한 줄 높이만 듦 → 페이지 스크롤 0. + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; + +export function footNote(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty b08-quantity__foot"; + p.textContent = text; + attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); + return p; +} diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 53d2223ed..819d20049 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -34,6 +34,7 @@ import { type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; export type MaterialSheetId = "material" | "concrete" | "quantity"; @@ -51,11 +52,9 @@ interface Payload { const BASIS = "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { - material: - "합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", - concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", - quantity: - "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요", + material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", + concrete: "㎥당 배합은 칸에서 고침", + quantity: "단위수량은 칸에 수기 입력", }; const HIDDEN = "값 없는 줄은 숨김"; @@ -190,9 +189,7 @@ function excelDiffs(s: MaterialSheets): string { if (want != null && (row.surcharge ?? 0) !== want) out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`); } - return out.length - ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` - : ""; + return out.length ? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")}` : ""; } /** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */ @@ -213,7 +210,7 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { const blank = s.quantity.rows.filter((row) => [...keys].every((k) => row.units[k] == null)); const copied = blank.filter((row) => source.snapshot?.units?.[row.id]).length; return blank.length - ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 비었음 ${blank.length}줄(사본 자재에 콘크리트 · 모르터 없음 ${copied} · 사본 자재 없음 · 미연결 ${blank.length - copied}) — 자재수량 탭 그 열에 넣음` + ? `콘크리트소요 빈 까닭 — 콘크리트(기계) · 모르터 1:3 단위수량 빈 ${blank.length}줄(사본에 없음 ${copied} · 미연결 ${blank.length - copied}) — 자재수량 탭에서 넣음` : ""; } @@ -340,10 +337,7 @@ export async function renderMaterialSheet( const toolbar = document.createElement("div"); toolbar.className = "b08-material__toolbar"; const notes = document.createElement("div"); - notes.append( - note(BASIS), - note(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`), - ); + notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { // 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로 const blank = blankUnitRows(sheets); @@ -352,12 +346,12 @@ export async function renderMaterialSheet( ...(payload.notes ?? []), ]; if (why.length) { - const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`); + const reason = footNote(`빈 칸 까닭 — ${why.join(" · ")}`); reason.dataset.b08MaterialWhy = ""; notes.append(reason); } } - const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 + const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 concreteNote.dataset.b08MaterialConcreteWhy = ""; if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); @@ -390,7 +384,7 @@ export async function renderMaterialSheet( }, }); table.root.dataset.b08Material = sheet; - const excel = note(""); + const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { built = BUILD[sheet](sheets, overrides); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index cf2275cd9..8de5e6c50 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -24,6 +24,16 @@ visibility: hidden; } +/* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */ +.b08-quantity__foot { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); +} + /* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */ .b08-material__toolbar { display: flex; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index ba9a28b1e..a8a13ef89 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -46,6 +46,7 @@ import { } from "./B08_Quantity_Summary_Data"; import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; +import { footNote } from "./B08_Quantity_UI_Foot"; import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { @@ -258,7 +259,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const summary = live(); const parts: HTMLElement[] = []; if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); - for (const text of summary?.notes ?? []) parts.push(note(text)); + if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄 if (!columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 0ee98c311..cf25e1acf 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -56,7 +56,7 @@ PARTS = ("labor", "material", "expense") COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} DEPTH = 4 # 하위 산근 깊이 상한 _Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) -NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)" +NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음" EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음" diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index bd47dacfb..68ed5b3f5 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -61,7 +61,7 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; -import { isGroupTab, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +import { isGroupTab, lineHint, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -243,7 +243,7 @@ export function renderB09Estimation(root: HTMLElement): void { ); return made; }; - const hint = spec.hint ? note(spec.hint) : null; + const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 if (isGroupTab(tab)) { const redraw = (): void => show(tab); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index fdd74a838..4eb8eb286 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -52,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`, + `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`, + `굳힌 단가(${madeText(payload)}) 기준 · 바꾸려면 [업데이트]`, ); function madeText(payload: Row): string { @@ -104,7 +104,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { rows, frozen: 2, mergeSame: ["part"], - hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표", + hint: "인건비 줄을 누르면 노무비목록표로 이동", link: (i) => { const row = rows[i]; return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null; @@ -196,7 +196,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)", + hint: "「적용 단가」 칸을 누르면 출처 고르기 · 「관급 · 별도」 칸을 누르면 구분 바꿈 · 견적 단가는 수기 입력", tip: (i, key) => key.startsWith("p:") && rows[i] ? sourceInfo( @@ -297,7 +297,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } : null, frozen: 2, - hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`, + hint: LINK_HINT, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; @@ -317,7 +317,7 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`, + hint: LINK_HINT, link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null), }; }; @@ -388,7 +388,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 1, - hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭", + hint: LINK_HINT, link: (i) => links[i] ?? null, level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 8363c7930..1893d4ddc 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -60,10 +60,10 @@ export const buildBill: TabBuild = (ctx): BillTable => { total: { name: "순공사비 계(관급 제외)", ...direct }, frozen: 1, hint: - "기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " + - "금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " + - "조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " + - `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, + "줄을 우클릭하면 추가 · 지움 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" + + (direct.blank + ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` + : ""), editable: (i, key) => { const r = rows[i]; if (!r) return false; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 3ff2cab15..8112b6b76 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -122,7 +122,7 @@ export function levelBar( return button; }); const guide = document.createElement("p"); - guide.className = "ui-general-block__empty"; + guide.className = "ui-general-block__empty b09-estimation__line-hint"; guide.textContent = GUIDE; attachHoverInfo(guide, GUIDE_MORE); const sync = (): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 313a30d7a..bc57acacb 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -178,8 +178,8 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine /* ── 그리기 ───────────────────────────────────────────────────────────── */ /** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */ -function lineHint(hint: HTMLElement | null): HTMLElement | null { - if (!hint) return null; +export function lineHint(hint: HTMLElement | null): HTMLElement | null { + if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint; hint.classList.add("b09-estimation__line-hint"); attachHoverInfo(hint, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); return hint; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 50b500e2f..26490385e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -27,8 +27,7 @@ const EMPTY = "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; /** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1) -const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; -const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김"; +const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)"; /** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : ""); const blankTip = (b: Row | undefined): string | null => @@ -65,7 +64,7 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 3, - hint: `호표 = 일위대가표 블록 합계 · ${BLANK} · 줄을 누르면 그 블록으로`, + hint: `줄을 누르면 그 블록으로 이동 · ${BLANK}`, link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null), tip: (i, key) => key === "spec" @@ -152,7 +151,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 1, - hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, + hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), level: blockLevel("name", (i) => titles.has(i)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1776afdf3..fc51ab5d0 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -56,9 +56,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => { ], rows, frozen: 2, - hint: - "산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + - notes(sheets), + hint: "줄을 누르면 그 블록으로 이동" + notes(sheets), link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null), }; @@ -159,8 +157,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { rows, frozen: 1, hint: - "산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + - notes(sheets), + "줄을 누르면 단가를 낸 탭으로 이동 · 자재 적용 단가를 고치면 [저장] 뒤 반영" + notes(sheets), link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), level: blockLevel("text", (i) => titles.has(i)), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 5fd4fba08..f3cef1dd2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -18,8 +18,7 @@ import { type TabTable, } from "./B09_Estimation_UI_Tabs"; -const HINT = - "총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표"; +const HINT = "줄을 누르면 목록표로 이동"; /** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ const hint = (payload: Row, extra = ""): string => { @@ -28,7 +27,7 @@ const hint = (payload: Row, extra = ""): string => { }; /** 줄이 없을 때 표 위 맨 앞 안내 */ -const EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐"; +const EXPENSE_NONE = "경비 자원 없음"; function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild { return ({ payload }): TabTable => { @@ -86,7 +85,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => { } : null, frozen: 2, - hint: hint(payload, " · 비목마다 ROUND(시간 × 시간당, 0)"), + hint: hint(payload), link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 96a3691aa..d319eed11 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -211,11 +211,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["part"], - hint: - "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + - "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + - "수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " + - "공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", + hint: "요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || (key === "hand" && String(rows[i]?.key) in HAND), @@ -292,7 +288,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["group", "part"], - hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", + hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), link: (i) => { diff --git a/resources/tester/helper_236_1_fill_sizes.cjs b/resources/tester/helper_236_1_fill_sizes.cjs new file mode 100644 index 000000000..64c240b5d --- /dev/null +++ b/resources/tester/helper_236_1_fill_sizes.cjs @@ -0,0 +1,72 @@ +/* 236-1 — 표 아래 안내 글이 한 줄일 때 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)이 + * 창 크기마다 문서 맨 아래 = 창 끝(페이지 스크롤 0)이 되는지 단위검증 헬퍼. JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +const ONE_LINE = 18; // 안내 글 한 줄(caption 글꼴) 높이 +const win = { innerHeight: 0, scrollY: 0, addEventListener() {}, removeEventListener() {} }; +const observers = []; +class RO { + constructor(fn) { + observers.push(fn); + } + observe() {} + disconnect() {} +} +const el = (css = {}, height = 0) => ({ + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: true, + style: {}, + getBoundingClientRect() { + return { top: this.top, height: this.height }; + }, +}); +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const box = { exports: {} }; +new Function( + "exports", + "module", + "require", + "window", + "document", + "getComputedStyle", + "ResizeObserver", + js, +)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO); + +const out = []; +for (const h of [1080, 768, 600]) { + win.innerHeight = h; + observers.length = 0; + // 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [표 칸][안내 한 줄] + const body = el({ paddingBottom: "8px" }); + const card = el({ paddingBottom: "12px", rowGap: "8px" }); + const root = el(); + const hint = el({}, ONE_LINE); + card.parentElement = body; + root.parentElement = card; + hint.parentElement = card; + root.nextElementSibling = hint; + root.top = 200; + box.exports.fillViewport(root); + observers.forEach((fn) => fn()); + const grow = parseFloat(root.style.maxHeight); + const below = box.exports.spaceBelow(root); + out.push({ viewport: h, bottom: root.top + grow + below, grow, below }); +} +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_160_3_b08_unmatched_total.py b/resources/tester/test_160_3_b08_unmatched_total.py index 2bb24e775..eacc1d617 100644 --- a/resources/tester/test_160_3_b08_unmatched_total.py +++ b/resources/tester/test_160_3_b08_unmatched_total.py @@ -68,9 +68,7 @@ def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch): monkeypatch.setattr(router, "structure_summary", summary) monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"]) got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({}))) - assert got["notes"] == [ - "2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡" - ] + assert got["notes"] == ["2공구 열 맞춤 없음 2종 — 이형철근 D13 ton · 토목용부직포 ㎡"] SC = {"층": "project", "주인": None, "키": "SC-000001"} diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index de80196ea..9cd4645bc 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -14,8 +14,8 @@ SPOTS = { "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Sangeun.py": ['"수기 줄" if'], - "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"], - "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가는 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"'], "ui_template/ui_template_locale_b2.ts": [ "수량 원천: 수기 입력(직접비)", "수량 원천: 수기 입력 공종", diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py index 21fd23dcb..97ff1246e 100644 --- a/resources/tester/test_183_2_b09_compare_head.py +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -27,4 +27,4 @@ def test_견적은_단가_견적서_그_밖은_단가_하나(): def test_견적_면_수기_칸_없앰(): body = _compare() assert 'key === "g:견적"' not in body and "quote_page" not in body - assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body + assert "견적 단가는 수기 입력" in body and "견적 단가 · 면" not in body diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 653d8a64d..78f99583a 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -279,7 +279,7 @@ def test_표_칸_강조_번호_연결_칸(): assert out["cls"] == ["is-edited", None, None] assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]" assert out["edit"] == [True, False, True, True, False] - assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"] + assert "엑셀 꼴 고정" not in out["hint"] and "우클릭" in out["hint"] assert out["renamed"] == "절토공" # 번호는 떼고 이름만 diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py new file mode 100644 index 000000000..edd8941c1 --- /dev/null +++ b/resources/tester/test_236_1_hint_one_line.py @@ -0,0 +1,160 @@ +"""236-1 B08 · B09 표 아래 안내 글 — 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0. + +- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음 +- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버) +- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) +- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B08 = ROOT / "B08_Quantity" +B09 = ROOT / "B09_Estimation" + +STRING = re.compile(r'"([^"\\\n]*)"|`([^`\n]*)`') +FORBID = ["ROUND", " = ", "÷", "×", "Σ", "M01", "역전개", "원문"] +CHAPTER = re.compile(r"\(\d{2,3}-\d\)|\b\d{1,3}-\d-\d\b|\b\d{2,3}-\d\b") +MAX_PIECE = 70 + + +def _read(path: Path) -> str: + return path.read_text("utf-8") + + +def _pieces(expr: str) -> list[str]: + out = [] + for m in STRING.finditer(expr): + text = m.group(1) if m.group(1) is not None else m.group(2) + out.append(re.sub(r"\$\{[^}]*\}", "", text).strip()) + return [p for p in out if p] + + +def _b09_hints() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for path in sorted(B09.glob("B09_Estimation_UI_Tabs_*.ts")): + src = _read(path) + for m in re.finditer(r"\bhint:\s*(.*?),\n\s+\w+[:(]", src, re.S): + found += [(path.name, p) for p in _pieces(m.group(1))] + for m in re.finditer( + r"^const (?:LINK_HINT|BLANK|HINT|EXPENSE_NONE|GUIDE)\s*=\s*(.*?);\n", src, re.S | re.M + ): + found += [(path.name, p) for p in _pieces(m.group(1))] + return found + + +def _b08_hints() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for name in ( + "B08_Quantity_UI_Earthwork.ts", + "B08_Quantity_UI_Earthwork_Sheets.ts", + "B08_Quantity_UI_Material.ts", + ): + src = _read(B08 / name) + for m in re.finditer(r"footNote\(((?:\s|\+|\"[^\"\n]*\"|`[^`\n]*`)+)\)", src): + found += [(name, p) for p in _pieces(m.group(1))] + for m in re.finditer(r"^const HINTS[^=]*=\s*\{(.*?)\n\};", src, re.S | re.M): + found += [(name, p) for p in _pieces(m.group(1))] + for m in re.finditer(r"^const HIDDEN\s*=\s*(.*?);\n", src, re.S | re.M): + found += [(name, p) for p in _pieces(m.group(1))] + return found + + +def _server_notes() -> list[tuple[str, str]]: + found: list[tuple[str, str]] = [] + for name in ("Router_Summary", "Router_Material"): + src = _read(B08 / f"B08_Quantity_{name}.py") + for m in re.finditer(r"notes\.append\(\s*(.*?)\)\n", src, re.S): + found += [(name, p) for p in _pieces(m.group(1).replace('f"', '"'))] + sangeun = _read(B09 / "B09_Estimation_Sangeun.py") + for m in re.finditer(r'^(?:NO_SOURCE|EMPTY) = "([^"]*)"', sangeun, re.M): + found.append(("Sangeun", m.group(1))) + return found + + +def _all() -> list[tuple[str, str]]: + return _b09_hints() + _b08_hints() + _server_notes() + + +def test_안내_글_모음이_비지_않음(): + assert len(_b09_hints()) >= 20 + assert len(_b08_hints()) >= 4 + assert len(_server_notes()) >= 7 + + +def test_제작_셈_설명_낱말_없음(): + bad = [] + for where, text in _all(): + bad += [(where, token, text) for token in FORBID if token in text] + if CHAPTER.search(text): + bad.append((where, "장 번호", text)) + assert not bad, bad + + +def test_안내_글_조각_짧음(): + long = [(w, t) for w, t in _all() if len(t) > MAX_PIECE] + assert not long, long + + +# ── 늘 한 줄 ─────────────────────────────────────────────────────────────── + + +def _block(css: str, selector: str) -> str: + m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css) + assert m, selector + return m.group(1) + + +def test_B09_안내_글_한_줄(): + css = _read(B09 / "B09_Estimation_UI_Page.css") + block = _block(css, ".b09-estimation__line-hint") + assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + shell = _read(B09 / "B09_Estimation_UI_Shell.ts") + assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄 + assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts") + level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts") + assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level + + +def test_B08_안내_줄_한_줄(): + css = _read(B08 / "B08_Quantity_UI_Page.css") + block = _block(css, ".b08-quantity__foot") + assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block + assert "display" not in block # hidden 속성(콘크리트 빈 까닭)을 덮지 않음 + foot = _read(B08 / "B08_Quantity_UI_Foot.ts") + assert "b08-quantity__foot" in foot and "attachHoverInfo" in foot + for name in ("Earthwork", "Earthwork_Sheets", "Material", "Summary"): + assert "footNote" in _read(B08 / f"B08_Quantity_UI_{name}.ts"), name + + +def test_B08_표_아래_안내는_footNote만(): + material = _read(B08 / "B08_Quantity_UI_Material.ts") + assert "note(BASIS)" not in material # 「구조물 수량 기준 …」 제작 설명 줄 없음 + assert "const excel = footNote(" in material + assert "const concreteNote = footNote(" in material + 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 # 제작 설명 줄 없음 + + +# ── 창 끝까지 셈 ──────────────────────────────────────────────────────────── + + +def test_창_크기마다_한_줄_안내_뒤_창_끝(): + proc = subprocess.run( + ["node", str(ROOT / "resources" / "tester" / "helper_236_1_fill_sizes.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + rows = json.loads(proc.stdout) + assert [r["viewport"] for r in rows] == [1080, 768, 600] + for r in rows: + assert r["below"] == 18 + 8 + 12 + 8 # 안내 한 줄 + 줄 사이 + 카드 + 본문 여백 + assert r["bottom"] == r["viewport"], r # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0