diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index d7d3ff6c4..bb293e404 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -67,6 +67,8 @@ export interface SvgZoomPanOptions { content?: ContentBounds; /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */ wheelZoom?: boolean; + /** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ + panStart?: (event: PointerEvent) => boolean; } /** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */ @@ -146,8 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { if (event.button === 1) event.preventDefault(); }); svg.addEventListener("pointerdown", (event) => { - // 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다. - if (event.button !== 1) return; + // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. + if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; event.preventDefault(); panning = true; moved = false; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 77b558157..9dfc0bae7 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -2,12 +2,12 @@ * B08_Quantity_Milestone_Layout.ts * 거리이정표 다이어그램 자리 계산(PLAN 244-2) — 화면 없음 · 형 import 만(시험이 node 로 돌림). * - * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 업체들 - * 업체 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 업체 경위도(모달 주소 검색)가 있으면 + * 상자 = 공사지(시점 · 중점 한 컨테이너 · 가운데) · 기준점 · 거래처들 + * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) - * 선 = 업체 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * ========================================================================== */ @@ -22,6 +22,8 @@ export interface DiagramBox { w: number; h: number; label: string; + /** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */ + items?: string; } export interface DiagramLine { @@ -160,10 +162,10 @@ export function layoutMilestone( line("base", "start", legText(data.base.legs)); } - // 업체 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, - Array<{ id: string; label: string; anchor: string; deg: number; legs: Leg[] }> + Array<{ id: string; label: string; items: string; anchor: string; deg: number; legs: Leg[] }> >(); const used = new Set(); const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; @@ -173,7 +175,7 @@ export function layoutMilestone( }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); let auto = 0; - /** 방위 안 고른 업체 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ + /** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ const free = (anchor: string): number => { const turn = anchor === "base" ? baseDeg : 0; const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; @@ -187,7 +189,10 @@ export function layoutMilestone( used.add(keyOf(anchor, d)); const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push({ id: r.id, label: r.name || "업체", anchor, deg: d, legs: r.legs }); + const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); + groups + .get(key)! + .push({ id: r.id, label: r.name || "거래처", items, anchor, deg: d, legs: r.legs }); } for (const group of groups.values()) { group.forEach((g, k) => { @@ -205,6 +210,7 @@ export function layoutMilestone( w: BOX.w, h: BOX.h, label: g.label, + items: g.items, }); line(g.id, g.anchor === "base" ? "base" : "start", legText(g.legs)); }); @@ -229,7 +235,7 @@ export function kakaoRouteUrl(points: Array<{ name: string; lon: number; lat: nu return `https://map.kakao.com/link/by/car/${points.map(part).join("/")}`; } -/** 선 글 한 줄 → 그 구간 — from = 업체 id(업체 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; @@ -254,3 +260,14 @@ export function setLeg( if (leg) Object.assign(leg, patch); return data; } + +/** 다이어그램 팬 시작 — 가운데 버튼 · 빈 곳 왼쪽 끌기(상자 · 선 글 · [+] 위는 그 화면 고르기 몫 · 244-6) */ +export const PAN_SKIP = ".b08-milestone__box, .b08-milestone__km, .b08-milestone__plus"; +export function panStart(e: { button: number; target: EventTarget | null }): boolean { + if (e.button === 1) return true; + const target = e.target as { closest?: (selector: string) => unknown } | null; + return e.button === 0 && !target?.closest?.(PAN_SKIP); +} + +/** 팬 한계 — 원배율에서도 그림 둘레 한 폭씩 끌어 옮길 수 있게(공용 팬은 이 범위까지 엶) */ +export const panContent = (w: number, h: number) => ({ x: -w, y: -h, width: 3 * w, height: 3 * h }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 2c55be329..adef962c1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -4,6 +4,13 @@ display: flex; flex-direction: column; gap: var(--spacing-8); + /* 틀 너비 = 표 너비(244-6) — 다이어그램은 너비를 보태지 않고 표에 맞춰 늘음 */ + width: fit-content; + max-width: 100%; +} + +.b08-milestone > .b08-milestone__diagram { + contain: inline-size; } .b08-milestone__route { @@ -147,3 +154,9 @@ .b08-milestone__leg-edit .ui-input { width: 80px; } + +/* 거래처 상자 윗줄 = 항목(244-6) */ +.b08-milestone__items { + fill: var(--color-text-secondary); + font-size: 12px; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index aecaaa8b9..8984b5099 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -1,11 +1,11 @@ /* ============================================================================= * B08_Quantity_UI_Milestone.ts - * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름). + * B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름). * 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음). * - * [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) + * [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고) * 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈 - * 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도 + * 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도 * 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌) * 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State` * ========================================================================== */ @@ -34,7 +34,7 @@ import { const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; const COLUMNS: DataColumn[] = [ { key: "item", label: "항목", width: 120 }, - { key: "company", label: "업체 이름", width: 120 }, + { key: "company", label: "거래처 이름", width: 120 }, { key: "dest", label: "목적지", width: 72 }, { key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM }, { key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM }, @@ -45,13 +45,13 @@ const COLUMNS: DataColumn[] = [ { key: "note", label: "비고", width: 160 }, ]; -/** 새 경로(업체) — 캐시에 넣고 id 를 돌려줌 */ +/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { const data = liveMilestone(projectId); const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`; data.routes.push({ id, - name: `업체 ${data.routes.length + 1}`, + name: `거래처 ${data.routes.length + 1}`, address: "", via_base: true, dir: null, @@ -141,7 +141,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { }; tabs.replaceChildren( tab("table", "거리이정표"), - ...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)), + ...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)), ); }; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index ef4cbdbae..6e5d74a6d 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -2,17 +2,25 @@ * B08_Quantity_UI_Milestone_Diagram.ts * [거리이정표] 하위 탭 위쪽 다이어그램(PLAN 244-2) — SVG · 자리 = `…_Milestone_Layout`. * - * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체들 · 선마다 「도로 구분 km」 글 - * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 업체 = 항목 정보 · 공사지 = 시점 주소 · + * 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 거래처들 · 선마다 「도로 구분 km」 글 + * 상자 누름 = 모달(244-4 · 탭 이동 아님 · `…_UI_Milestone_Modal`) — 거래처 = 항목 정보 · 공사지 = 시점 주소 · * 기준점 = 공통 구간 · 공사지 · 기준점 상자 [+] = 항목 추가 모달 · 조작 = 캐시(`…_UI_Milestone_State`) - * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 팬 · 줌 = 공용 `A00_Common/b_svg_zoom_pan`(휠 = 줌 · 가운데 버튼 · 빈 곳 왼쪽 끌기 = 팬 · 빈 곳 더블클릭 = 원배율) + * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * ========================================================================== */ -import { createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; -import { layoutMilestone, legAt, setLeg, type DiagramBox } from "./B08_Quantity_Milestone_Layout"; +import { + layoutMilestone, + legAt, + panContent, + panStart, + setLeg, + type DiagramBox, +} from "./B08_Quantity_Milestone_Layout"; import { openAddItem, openBaseInfo, @@ -138,7 +146,8 @@ export function createDiagram( role: "img", "aria-label": "거리이정표 다이어그램", }); - root.style.maxWidth = "100%"; + // 틀 너비 = 아래 표 너비(244-6) · 높이는 비율대로(팬 · 줌 화면 px ↔ 그림 좌표가 어긋나지 않게) + root.style.width = "100%"; root.style.height = "auto"; const layer = svg("g", {}); const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); @@ -162,9 +171,19 @@ export function createDiagram( "text-anchor": "middle", "dominant-baseline": "central", }, - box.label, + box.kind === "route" && box.items ? "" : box.label, ), ); + if (box.kind === "route" && box.items) { + // 거래처 상자 = 항목(윗줄) + 거래처 이름(아랫줄) + const x = box.x + box.w / 2; + const two = g.lastChild as SVGTextElement; + two.setAttribute("y", String(box.y + box.h / 2 - 8)); + two.append( + svg("tspan", { x, class: "b08-milestone__items" }, box.items), + svg("tspan", { x, dy: 16 }, box.label), + ); + } // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); g.setAttribute("role", "button"); @@ -194,7 +213,15 @@ export function createDiagram( }); shift.append(text); } - host.replaceChildren(root); + const addBase = createButton({ + label: "기준점 +", + size: "sm", + variant: "ghost", + onClick: () => openBaseInfo(projectId, true), + }); + addBase.dataset.b08MilestoneAddBase = ""; + addBase.hidden = data.base.enabled; + host.replaceChildren(el("div", { className: "b08-milestone__bar", children: [addBase] }), root); attachSvgZoomPan({ svg: root, layer, @@ -204,6 +231,8 @@ export function createDiagram( initial: zoomState, onChange: (state) => (zoomState = state), wheelZoom: true, + content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) + panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기 }); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index c18484595..d3cd4d9e1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -2,9 +2,9 @@ * B08_Quantity_UI_Milestone_Modal.ts * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. * - * [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = - * 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 - * 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] + * [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = + * 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 + * 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 @@ -36,7 +36,12 @@ import { type Road, } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; -import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State"; +import { + liveMilestone, + putMilestone, + roadOptions, + siteInfo, +} from "./B08_Quantity_UI_Milestone_State"; const DEST_OPTIONS = [ { value: "start", text: "시점" }, @@ -145,7 +150,7 @@ function placeOf( const head = (text: string): HTMLElement => el("h4", { text }); -/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ +/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ export function openAddItem( projectId: string, viaBase: boolean, @@ -156,7 +161,7 @@ export function openAddItem( closeLabel: "닫기", mount: (body, close, actions) => { const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); - const company = createInputField({ label: "업체 이름", compact: true }); + const company = createInputField({ label: "거래처 이름", compact: true }); const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); const address = buildAddressField(null); item.input.dataset.b08MilestoneAdd = "item"; @@ -166,7 +171,7 @@ export function openAddItem( variant: "filled", onClick: () => { const name = company.input.value.trim(); - if (!name) return company.setError("업체 이름을 넣으세요."); + if (!name) return company.setError("거래처 이름을 넣으세요."); const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; const place = placeOf(address, { address: "" }); const data = liveMilestone(projectId); @@ -184,23 +189,23 @@ export function openAddItem( item.root, company.root, dest.root, - head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"), + head("거래처 위치 — 주소 검색(방위는 이 자리로 자동)"), address.root, ); }, }); } -/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ +/** 거래처 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ export function openRouteInfo(projectId: string, routeId: string): void { const data = liveMilestone(projectId); const route = data.routes.find((r) => r.id === routeId); if (!route) return; openModal({ - title: `항목 정보 — ${route.name || "업체"}`, + title: `항목 정보 — ${route.name || "거래처"}`, closeLabel: "닫기", mount: (body, close, actions) => { - const name = createInputField({ label: "업체 이름", compact: true }); + const name = createInputField({ label: "거래처 이름", compact: true }); name.input.value = route.name; name.input.dataset.b08MilestoneInfo = "name"; const via = createCheckbox("기준점 거침"); @@ -232,7 +237,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { onClick: async () => { if ( !(await showConfirmDialog( - `「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`, + `「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`, "삭제", )) ) @@ -252,7 +257,7 @@ export function openRouteInfo(projectId: string, routeId: string): void { head("표 줄 — 항목 · 목적지"), rows.root, head( - `구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, + `구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, ), legs.root, head("주소 · 지도"), @@ -263,22 +268,26 @@ export function openRouteInfo(projectId: string, routeId: string): void { } /** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ -export function openBaseInfo(projectId: string): void { +export function openBaseInfo(projectId: string, create = false): void { const base = liveMilestone(projectId).base; openModal({ - title: `기준점 — ${base.name || "기준점"}`, + title: create ? "기준점 추가" : `기준점 — ${base.name || "기준점"}`, closeLabel: "닫기", mount: (body, close, actions) => { const name = createInputField({ label: "기준점 이름", compact: true }); name.input.value = base.name; const address = buildAddressField(base.address || null); - const legs = legsEditor(base.legs, "1차포장"); + const legs = legsEditor( + base.legs.length ? base.legs : [{ road: "1차포장", km: null }], + "1차포장", + ); const save = createButton({ - label: "고침", + label: create ? "등록" : "고침", variant: "filled", onClick: () => { const fresh: MilestoneData = liveMilestone(projectId); Object.assign(fresh.base, placeOf(address, fresh.base), { + enabled: true, name: name.input.value.trim(), legs: legs.value(), }); @@ -286,7 +295,21 @@ export function openBaseInfo(projectId: string): void { close(); }, }); - actions.append(save); + // 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로) + const off = createButton({ + label: "기준점 없앰", + variant: "danger", + onClick: () => { + const fresh: MilestoneData = liveMilestone(projectId); + fresh.base.enabled = false; + putMilestone(projectId, fresh); + close(); + }, + }); + off.hidden = create; + save.dataset.b08MilestoneBase = "save"; + off.dataset.b08MilestoneBase = "off"; + actions.append(off, save); body.append( name.root, head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), @@ -313,14 +336,56 @@ interface SiteAddress { /** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ - title: "공사지 — 시점", + title: "공사지 — 시점 · 중점", closeLabel: "닫기", - mount: (body) => { + mount: (body, close, actions) => { const text = (label: string, value: string): HTMLElement => el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); + // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) + const data = liveMilestone(projectId); + const site = siteInfo(); + const km = (m: number | null | undefined): string => + m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); + const mid = createInputField({ + label: "중점 측점 (m)", + type: "number", + min: 0, + compact: true, + }); + mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; + mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); + mid.input.dataset.b08Milestone = "mid_station_m"; + const road = createSelectField({ + label: "시점 → 중점 도로", + options: roadOptions, + compact: true, + }); + road.select.value = data.site.mid_road; + const save = createButton({ + label: "고침", + variant: "filled", + onClick: () => { + const fresh = liveMilestone(projectId); + const value = Number(mid.input.value); + fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; + fresh.site.mid_road = road.select.value as Road; + putMilestone(projectId, fresh); + close(); + }, + }); + save.dataset.b08MilestoneSite = "save"; + actions.append(save); const map = buildAddressMap(); const info = el("div", { children: [text("주소", "불러오는 중…")] }); - body.append(info, map.root); + body.append( + line(mid.root, road.root), + text( + "시점 = 노선 첫 점(측점 0)", + `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, + ), + info, + map.root, + ); request(`/projects/${projectId}/quantity/milestone/site-address`).then( (s) => { const ll = (p: [number, number] | null): string => diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index df5c65bbb..eb09f50b5 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -2,7 +2,7 @@ * B08_Quantity_UI_Milestone_Route.ts * 거리이정표 경로 하위 탭(PLAN 244-1 · 244-4) — 길찾기 지도 결과 그림 하나만(구간 · 표 줄은 항목 정보 모달). * - * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 업체 좌표 · + * 길찾기 API 키 없음 → [다음 지도에서 길찾기] = 새 창 map.kakao.com 자동차 길찾기(출발 = 거래처 좌표 · * 경유 = 기준점 좌표(거침) · 도착 = 목적지 좌표) → 사용자가 길 확인 → [화면 캡처] = getDisplayMedia 로 * 고른 창 한 장 → 이 탭 그림(그림 올리기 길) · [그림 올리기] · [그림 지우기] 곁에 * 그림 = 올리는 즉시 서버(교체 · 지우기) @@ -65,7 +65,7 @@ export interface MilestoneView { refresh: () => void; } -/** 새 창 길찾기 지점 — 업체 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ +/** 새 창 길찾기 지점 — 거래처 → (기준점 거침 · 좌표 있으면) → 목적지(첫 표 줄 · 시점 · 중점) */ export function routePoints( d: MilestoneData, r: MilestoneRoute, @@ -74,7 +74,7 @@ export function routePoints( const dest = r.rows[0]?.dest ?? "start"; const to = dest === "mid" ? site?.mid_lonlat : site?.start_lonlat; if (r.lon == null || r.lat == null || !to) return null; - const points = [{ name: r.name || "업체", lon: r.lon, lat: r.lat }]; + const points = [{ name: r.name || "거래처", lon: r.lon, lat: r.lat }]; if (d.base.enabled && r.via_base && d.base.lon != null && d.base.lat != null) points.push({ name: d.base.name || "기준점", lon: d.base.lon, lat: d.base.lat }); points.push({ name: `임도 ${DEST_TEXT[dest]}`, lon: to[0], lat: to[1] }); @@ -151,7 +151,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const points = r ? routePoints(d, r) : null; if (!points) return showToast( - "업체 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", + "거래처 주소(다이어그램 상자 → 항목 정보)와 노선 시점을 먼저 정하세요.", "info", ); window.open(kakaoRouteUrl(points), "_blank", "noopener"); @@ -205,7 +205,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { const r = d.routes.find((x) => x.id === routeId); if (!r) return; const dest = DEST_TEXT[r.rows[0]?.dest ?? "start"]; - title.textContent = `${r.name || "업체"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; + title.textContent = `${r.name || "거래처"} → ${dest}${r.address ? ` · ${r.address}` : ""}`; loadImage(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 45198f8fd..8ccf1c96f 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B08_Quantity_UI_Milestone_State.ts - * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점). + * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 · + * 공사지 · 기준점은 다이어그램 모달). * * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. @@ -8,11 +9,9 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import { createCheckbox } from "@ui/ui_template_checkbox"; -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; +import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; export interface SiteInfo { length_m: number | null; @@ -127,73 +126,3 @@ export async function imageBlobUrl(projectId: string, routeId: string): Promise< } export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); - -const km = (m: number | null | undefined): string => - m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); - -/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위 = 주소 좌표 자동) */ -export function milestoneCardBody(projectId: string | null): HTMLElement[] { - const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 }); - const midRoad = createSelectField({ - label: "시점 → 중점 도로", - options: roadOptions, - compact: true, - }); - const siteHint = el("p", { className: "b08-options__hint" }); - const has = createCheckbox("기준점 있음"); - const name = createInputField({ label: "기준점 이름", compact: true }); - const address = createInputField({ label: "기준점 주소", compact: true }); - mid.input.dataset.b08Milestone = "mid_station_m"; - has.input.dataset.b08Milestone = "base_enabled"; - const baseFields = el("div", { children: [name.root, address.root] }); - - const show = (): void => { - if (!projectId) return; - const data = liveMilestone(projectId); - const site = siteInfo(); - mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; - if (document.activeElement !== mid.input) - mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); - midRoad.select.value = data.site.mid_road; - siteHint.textContent = - `시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` + - `중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`; - has.input.checked = data.base.enabled; - baseFields.hidden = !data.base.enabled; - if (document.activeElement !== name.input) name.input.value = data.base.name; - if (document.activeElement !== address.input) address.input.value = data.base.address; - }; - const edit = (fn: (data: MilestoneData) => void): void => { - if (!projectId) return; - const data = liveMilestone(projectId); - fn(data); - putMilestone(projectId, data); - }; - mid.input.addEventListener("input", () => - edit((d) => { - const value = Number(mid.input.value); - d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; - }), - ); - midRoad.select.addEventListener("change", () => - edit((d) => (d.site.mid_road = midRoad.select.value as Road)), - ); - has.input.addEventListener("change", () => - edit((d) => { - d.base.enabled = has.input.checked; - if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }]; - }), - ); - name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value))); - address.input.addEventListener("input", () => - edit((d) => (d.base.address = address.input.value)), - ); - - const host = el("div", { - children: [mid.root, midRoad.root, siteHint, has.root, baseFields], - }); - show(); - onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤 - if (projectId) void loadMilestone(projectId).catch(() => undefined); - return [host]; -} diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index fe5160be2..62d0a3147 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -8,8 +8,8 @@ * 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) - * 거리이정표 › 공사지(중점 측점 · 시점 → 중점 도로) · 기준점 — `…_UI_Milestone_State`(캐시 `b08-milestone` · - * [저장] 한 번에 같이 PUT) + * 거리이정표 — 카드 없음(244-6 · 공사지 · 기준점은 다이어그램 모달) · 캐시 `b08-milestone` 은 + * [저장] 한 번에 같이 PUT(`…_UI_Milestone_State`) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. * ========================================================================== */ @@ -27,7 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; -import { milestoneCardBody, saveMilestone } from "./B08_Quantity_UI_Milestone_State"; +import { saveMilestone } from "./B08_Quantity_UI_Milestone_State"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -242,7 +242,6 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock), card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), - card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId)), actions, ); attachCollapsible(panel); diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index ac34f1342..3e87c8bfd 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -149,8 +149,10 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): "quantity", ] # 자재 탭 안 = 총괄 · 콘크리트소요 · 자재수량 assert 'select("material");' in page and 'select("summary");' in page - assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs] == sorted( - panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in tabs + cards = tabs[:-1] # 244-6 — 거리이정표 카드 없음(다이어그램 모달) + assert 'card("milestone"' not in panel + assert [panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards] == sorted( + panel.index(f'card("{tab}", "B08_Quantity_Tab_') for tab in cards ) assert 'card("summary", "B08_Quantity_Tab_StructureSummary", one.root),' in panel assert 'card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),' in panel diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index f7bed025c..c69fd4724 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -171,10 +171,7 @@ def test_메인_탭_하위_탭_추가_삭제(): assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state assert state.count('method: "PUT"') == 1 and "setTimeout" not in state options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") - assert ( - 'card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId))' - in options - ) + assert 'card("milestone"' not in options and "milestoneCardBody" not in options # 244-6 assert "await saveMilestone(projectId);" in options registry = _read("A00_Common/b_page_state.ts") assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 907c76e54..9b8958ed6 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -144,7 +144,7 @@ def test_화면_모달_팬줌_그림탭(): add = modal[ modal.index("export function openAddItem") : modal.index("export function openRouteInfo") ] - for text in ('label: "항목"', 'label: "업체 이름"', 'label: "목적지"', 'label: "등록"', + for text in ('label: "항목"', 'label: "거래처 이름"', 'label: "목적지"', 'label: "등록"', "buildAddressField(null)", "placeOf(address,"): # fmt: skip assert text in add assert "방위" not in add.replace("방위는 이 자리로 자동", "") and "dirOptions" not in modal diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 716657f71..d5c4ac8cd 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -53,7 +53,7 @@ def test_좌표_있음_공사지_기준_방위각(): def test_좌측_패널_방위_칸_없음_모달_안내(): state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "공사지에서 본 방위" not in state and "dirOptions" not in state - assert "d.base.dir" not in state and 'label: "기준점 주소"' in state + assert "d.base.dir" not in state # 244-6 — 기준점 칸은 다이어그램 기준점 모달로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] assert "주소를 고르면 다이어그램에 실제 방위로 놓임" in base diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py new file mode 100644 index 000000000..bada838af --- /dev/null +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -0,0 +1,138 @@ +"""244-6 B08 거리이정표 추가 고침(사용자 132 · 133번) — 표 폭 = 다이어그램 폭 · +거래처 상자 항목 글 · 좌측 패널 카드 없음(공사지 · 기준점 = 다이어그램 모달 · [기준점 +]) · +팬(가운데 · 빈 곳 왼쪽 끌기 · 원배율에서도) · 화면 글 「업체」 → 「거래처」. + +팬은 공용 `A00_Common/b_svg_zoom_pan.ts` + `…_Milestone_Layout.ts`(panStart · panContent)를 +node 로 돌려 pointerdown · pointermove · pointerup 사건을 실제로 보내 +그림 묶음 transform 이 바뀌는지 잼. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +UI = ("UI_Milestone.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts", + "UI_Milestone_Route.ts", "UI_Milestone_State.ts", "Milestone_Layout.ts") # fmt: skip + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script): + src = "".join( + IMPORTS.sub("", _read(rel)) + for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + "\n" + script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +PAN = """ +const W = 600, H = 400; +class Svg extends EventTarget { + classList = { add() {}, remove() {} }; + setPointerCapture() {} + releasePointerCapture() {} + getBoundingClientRect() { return { left: 0, top: 0, width: W, height: H }; } +} +const shape = { closest: (s: string) => (s.includes(".b08-milestone__box") ? {} : null) }; +const empty = { closest: () => null }; +function fire(svg: EventTarget, type: string, button: number, x: number, y: number, + target: unknown) { + const ev = new Event(type, { cancelable: true }); + for (const [k, v] of Object.entries({ button, clientX: x, clientY: y, pointerId: 1, target })) + Object.defineProperty(ev, k, { value: v }); + svg.dispatchEvent(ev); +} +function drag(button: number, target: unknown) { + const svg = new Svg(); + const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } }; + attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true, + content: panContent(W, H), panStart }); + fire(svg, "pointerdown", button, 100, 100, target); + fire(svg, "pointermove", button, 160, 130, target); + fire(svg, "pointerup", button, 160, 130, target); + return layer.t; +} +console.log(JSON.stringify({ + leftEmpty: drag(0, empty), leftShape: drag(0, shape), + middleShape: drag(1, shape), right: drag(2, empty), +})); +""" + + +def test_실제_포인터_사건으로_팬(): + got = _node(PAN) + # 원배율(1)에서도 빈 곳 왼쪽 끌기 · 가운데 버튼 끌기 = 그림 묶음이 끈 만큼 옮김 + assert got["leftEmpty"] == "translate(60 30) scale(1)" + assert got["middleShape"] == "translate(60 30) scale(1)" + assert got["leftShape"] == "" and got["right"] == "" # 상자 위 왼쪽 = 그 화면 고르기 몫 + + +def test_거래처_상자_항목_글(): + data = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "routes": [ + {"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""}, + {"item": " 장비 ", "dest": "mid", "note": ""}, + {"item": "골재", "dest": "mid", "note": ""}]}, + {"id": "k", "name": "", "address": "", "via_base": True, "dir": None, + "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, + ], + } # fmt: skip + lay = _node(f"console.log(JSON.stringify(layoutMilestone({json.dumps(data)}, null, 0)));") + boxes = {b["id"]: b for b in lay["boxes"]} + assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청" + assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처" + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'svg("tspan", { x, class: "b08-milestone__items" }, box.items)' in ui + assert 'svg("tspan", { x, dy: 16 }, box.label)' in ui + + +def test_표_폭_다이어그램_폭_좌측_카드_없음(): + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + block = css[css.index(".b08-milestone {") :] + assert "width: fit-content;" in block[: block.index("}")] # 틀 = 표 너비 + assert ".b08-milestone > .b08-milestone__diagram {\n contain: inline-size;" in css + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'root.style.width = "100%";' in ui # 다이어그램 = 틀 너비 + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert "children: [diagram.root, none, table.root]" in page + # 좌측 패널 카드 없음 · 공사지 · 기준점은 모달 + options = _read("B08_Quantity/B08_Quantity_UI_Options.ts") + assert "milestoneCardBody" not in options and "await saveMilestone(projectId);" in options + assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + site = modal[modal.index("export function openSiteInfo") :] + assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site + assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site + base = modal[modal.index("export function openBaseInfo") : modal.index("interface SiteAddress")] + assert "enabled: true," in base and 'label: "기준점 없앰"' in base + assert "fresh.base.enabled = false;" in base + assert 'label: "기준점 +"' in ui and "openBaseInfo(projectId, true)" in ui + assert "addBase.hidden = data.base.enabled;" in ui + assert "content: panContent(vw, vh)" in ui and "panStart," in ui + + +def test_화면_글_거래처(): + for name in UI: + text = _read(f"B08_Quantity/B08_Quantity_{name}") + assert "업체" not in text, name + modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") + assert 'label: "거래처 이름"' in modal + page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") + assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page + for name in UI: + assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700