feat(b08): 거리이정표 단가연결 램프 · 우클릭 단가연결 · 다이어그램 틀 높이 맞춤 (251-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 01:29:32 +09:00
co-authored by Claude Opus 5.5
parent 433d6d204b
commit 8eb389eb39
5 changed files with 100 additions and 5 deletions
@@ -268,3 +268,19 @@
.b08-milestone__route-addr {
color: var(--color-text-secondary);
}
/* 단가연결 램프(251-4) — 항목 칸 앞 · 노랑 = 없음 · 초록 = 연결 */
[data-b08-milestone="table"] td.b08-milestone__lamp::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background: var(--color-warning);
vertical-align: middle;
}
[data-b08-milestone="table"] td.b08-milestone__lamp.is-linked::before {
background: var(--color-success);
}
+42 -2
View File
@@ -9,11 +9,16 @@
* 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
* 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] ·
* [단가연결 풀기] — 창 = B09 설계내역서와 같은 `openPriceLink` · 경로 `ref` · B09 운반공 줄이 받음
* ========================================================================== */
import "./B08_Quantity_UI_Milestone.css";
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base";
import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link";
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
import {
@@ -35,6 +40,18 @@ import {
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
const ROAD = ["도로 구분 (km)"];
const SUM = ["계 (km)"];
/** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */
const LAMP = "b08-milestone__lamp";
/** 램프 호버 글 — 연결된 것 */
export function refText(ref: Record<string, unknown> | null | undefined): string {
if (!ref) return "단가연결 없음 — 줄 우클릭 [단가연결]";
const what =
ref.tab === "logic"
? `단가산출 ${String(ref.code ?? "")} ${String(ref.name ?? "")}`
: `일위대가 ${String(ref.name ?? ref.key ?? "")}`;
return `단가연결 — ${what.trim()}`;
}
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
@@ -53,7 +70,7 @@ export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColu
{ key: "un", label: "비포장", group: ROAD, ...KM },
];
return [
{ key: "item", label: "항목", width: 120 },
{ key: "item", label: "항목", width: 134 },
{ key: "company", label: "거래처 이름", width: 120 },
{ key: "dest", label: "목적지", width: 72 },
...roads,
@@ -84,6 +101,8 @@ export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}):
function tableView(projectId: string): MilestoneView {
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
const routeAt = (i: number) =>
liveMilestone(projectId).routes.find((r) => r.id === keys[i]?.route);
const makeTable = (columns: DataColumn[]) => {
const t = createDataTable({
columns,
@@ -91,6 +110,7 @@ function tableView(projectId: string): MilestoneView {
caption: "거리이정표",
empty: "",
editOnDouble: true,
cellClass: (i, key) => (key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP),
editable: (_i, key) => key === "item" || key === "company" || key === "note",
onEdit: (i, key, text) => {
const at = keys[i];
@@ -116,6 +136,26 @@ function tableView(projectId: string): MilestoneView {
});
trackCells(t.root);
t.root.dataset.b08Milestone = "table";
attachHoverInfo(
t.root,
(cell) => refText(routeAt(Number(cell.closest("tr")?.dataset.i))?.ref),
`td.${LAMP}`,
);
t.root.addEventListener("contextmenu", (event) => {
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
const route = tr ? routeAt(Number(tr.dataset.i)) : undefined;
if (!route) return;
event.preventDefault();
const link = (ref: Row | null, from?: Row): void =>
editRoute(projectId, route.id, (_, r) => {
// 호버 글 이름(사전조합 · 구조물 조합 = 들여온 대가 이름) — B09 는 tab · key 만 읽음
r.ref = ref && { ...ref, name: ref.name ?? from?.name ?? "" };
});
pickAtCell([
["단가연결", () => void openPriceLink({ apply: link })],
...(route.ref ? [["단가연결 풀기", () => link(null)] as [string, () => void]] : []),
]);
});
return t;
};
let mode = "false:false";
@@ -177,7 +217,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() || r.rows[0]?.item || `거래처 ${i + 1}`)),
);
};
@@ -421,12 +421,15 @@ export function createDiagram(
});
};
refresh();
// 틀 크기가 바뀌면(창 · 패널 접기) 그 비율로 다시 그림
// 틀 크기가 바뀌면(창 · 패널 접기 · 높이 = 58vh) 그 비율로 다시 그림 — 그림이 틀보다 크면 아래가 잘림(251)
let lastW = 0;
let lastH = 0;
new ResizeObserver(() => {
const w = host.clientWidth;
if (w && Math.abs(w - lastW) > 1) {
const h = host.clientHeight;
if (w && (Math.abs(w - lastW) > 1 || Math.abs(h - lastH) > 1)) {
lastW = w;
lastH = h;
refresh();
}
}).observe(host);
@@ -34,7 +34,8 @@ let lastCell: Element | null = null;
/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */
export function trackCells(root: HTMLElement): void {
root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true);
for (const type of ["click", "contextmenu"])
root.addEventListener(type, (e) => (lastCell = (e.target as Element).closest("td")), true);
}
export function pickAtCell(items: MapContextMenuItem[]): void {
@@ -107,3 +107,38 @@ def test_251_5_자동_줄_단가_줄_키_짝():
ts = (ROOT / "B09_Estimation" / "B09_Estimation_Calc_Bill.ts").read_text(encoding="utf-8")
assert 'ref_price(item.get("ref"), books, hopyo, item["key"])' in py
assert "refPrice(item.ref ?? null, book, hopyo, item.key)" in ts
def test_251_4_거리이정표_램프_우클릭_단가연결():
import json
import subprocess
src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.ts").read_text(encoding="utf-8")
fn = (
"export function refText"
+ src.split("export function refText")[1].split("\n}\n")[0]
+ "\n}\n"
)
js = (
fn
+ """
console.log(JSON.stringify([refText(null),
refText({ tab: "logic", code: "GF000134", name: "골재운반 모래" }),
refText({ tab: "dg", key: "DG:x", name: "회사 조합" })]));
"""
)
run = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=js, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert run.returncode == 0, run.stderr
assert json.loads(run.stdout) == [
"단가연결 없음 — 줄 우클릭 [단가연결]",
"단가연결 — 단가산출 GF000134 골재운반 모래",
"단가연결 — 일위대가 회사 조합",
]
# 램프 = 항목 칸 · 우클릭 = [단가연결] · 연결 있으면 [단가연결 풀기] · 창 = B09 와 같은 것
assert 'key !== "item" ? null : routeAt(i)?.ref ? `${LAMP} is-linked` : LAMP' in src
assert "openPriceLink({ apply: link })" in src and '"단가연결 풀기"' in src
diagram = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone_Diagram.ts").read_text("utf-8")
assert "Math.abs(h - lastH) > 1" in diagram # 틀 높이 바뀜 = 다시 그림(아래 잘림 없음)