Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -51,8 +51,8 @@
|
||||
}
|
||||
|
||||
.b05-route-profile__trailing > .b05-route-profile__popout {
|
||||
padding: 1px var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
}
|
||||
|
||||
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
|
||||
|
||||
@@ -25,13 +25,13 @@ import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profil
|
||||
import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { currentRoute, remountRoute } from "../router";
|
||||
import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
|
||||
import { createSelectionSync } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||
import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||
import {
|
||||
readStructurePick,
|
||||
restoreStructurePick,
|
||||
wireStructurePick,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection";
|
||||
import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
bindDesignView,
|
||||
@@ -123,14 +123,17 @@ export async function mountProfileView(
|
||||
profilePanel.setMinCoverPipes(pipes);
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
};
|
||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */
|
||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
|
||||
* 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1). */
|
||||
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
|
||||
const bare =
|
||||
id === null && chainageM !== null
|
||||
? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M)
|
||||
: undefined;
|
||||
if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`);
|
||||
else profilePanel.setSelectedStation(id);
|
||||
const sections = currentSectionDetail?.cross_sections ?? [];
|
||||
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
|
||||
if ("pipeChainageM" in target) {
|
||||
profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
profilePanel.setSelectedStation(target.stationId);
|
||||
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
|
||||
};
|
||||
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
|
||||
const profilePanel = createRouteProfilePanel(
|
||||
@@ -223,6 +226,10 @@ export async function mountProfileView(
|
||||
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
|
||||
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
|
||||
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
|
||||
regularChainage: (stationId) =>
|
||||
currentSectionDetail?.cross_sections.find((entry) => entry.station_id === stationId)
|
||||
?.chainage_m,
|
||||
remember: (chainageM) => rememberPick(chainageM, null),
|
||||
});
|
||||
|
||||
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼].
|
||||
* ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.)
|
||||
*
|
||||
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위
|
||||
* 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유).
|
||||
* 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 같은 줄 오른쪽
|
||||
* [배수유역도] · [새 창] 과 같다(공용 토큰 `--btn-bar-*`).
|
||||
*
|
||||
* 선택 흐름:
|
||||
* [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 구조물(`origin: "pipe"`)일 때만 붙는다 — 다른 구조물에는 대응하는 유역이 없다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { PIPE_MATCH_M, nearestWithin } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
@@ -38,6 +38,10 @@ export interface SelectionSyncPorts {
|
||||
/** 3D 코리도 구조물 강조(누가거리 기준) — 좌측 폼·종단에서 골라도 3D가 따라온다
|
||||
* (2026-09-04). 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다. */
|
||||
selectStructure3D?: (chainageM: number | null) => void;
|
||||
/** 일반 측점 id → 누가거리(없으면 undefined — 고름 한 칸을 안 건드림) — 일반 측점 고름도 고름 한 칸에 남기는 데 씀(188-1). */
|
||||
regularChainage?: (stationId: string) => number | undefined;
|
||||
/** 사용자 고름을 고름 한 칸(`structure-pick`)에 적음 — 마지막 고름 하나만 이김(188-1). */
|
||||
remember?: (chainageM: number | null) => void;
|
||||
}
|
||||
|
||||
export interface SelectionSync {
|
||||
@@ -71,9 +75,13 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
syncIrregularSelection(stationId) {
|
||||
if (ports.isSyncing()) return;
|
||||
syncBasinHighlight(stationId);
|
||||
const station = irregularOf(stationId);
|
||||
// 측점선 · 3D 띠 · 표 고름도 고름 한 칸에 — 일반 측점을 안 적어 옛 배관 고름이 이겼음(188-1)
|
||||
const at =
|
||||
stationId === null ? null : (station?.chainage_m ?? ports.regularChainage?.(stationId));
|
||||
if (at !== undefined) ports.remember?.(at);
|
||||
// 규칙 측점을 고른 것이면 구조물 폼은 건드리지 않는다(고를 항목이 없다).
|
||||
if (stationId !== null && !stationId.startsWith(prefix)) return;
|
||||
const station = irregularOf(stationId);
|
||||
ports.setSyncing(true);
|
||||
try {
|
||||
ports.selectSidebar(station ? station.chainage_m : null);
|
||||
@@ -113,3 +121,18 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 종단 그래프가 켤 것 — 측점 id 가 있으면 그 선 · 없으면 그 자리 관 알약(53-2) · 관도 없으면
|
||||
* 그 자리 측점선(188-1 · 횡단에서 고른 일반 측점이 종단으로 이어짐). */
|
||||
export function graphTargetAt(
|
||||
stationId: string | null,
|
||||
chainageM: number | null,
|
||||
pipes: ReadonlyArray<{ chainage_m: number }>,
|
||||
sections: ReadonlyArray<{ station_id: string; chainage_m: number }>,
|
||||
): { pipeChainageM: number } | { stationId: string | null } {
|
||||
if (stationId !== null || chainageM === null) return { stationId };
|
||||
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
|
||||
if (pipe) return { pipeChainageM: pipe.chainage_m };
|
||||
const card = nearestWithin(sections, chainageM, (entry) => entry.chainage_m);
|
||||
return { stationId: card?.station_id ?? null };
|
||||
}
|
||||
|
||||
@@ -566,8 +566,8 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 도구줄 — 요약줄 맨 앞(최대 기울기 칩 왼쪽). 버튼 크기는 그래프 위 틸팅 버튼과 같다
|
||||
(21×17px, 2026-09-02 사용자 지시). */
|
||||
/* 도구줄 — 요약줄 맨 앞(최대 기울기 칩 왼쪽). 단추 크기는 같은 줄 오른쪽 [배수유역도] · [새 창]
|
||||
과 같은 공용 토큰(`--btn-bar-*`). */
|
||||
.b05-route-profile__tools {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -577,14 +577,13 @@
|
||||
}
|
||||
|
||||
.b05-route-profile__tool {
|
||||
height: 17px;
|
||||
min-width: 21px;
|
||||
padding: 0 3px;
|
||||
padding: var(--btn-bar-padding);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent);
|
||||
color: var(--color-text);
|
||||
font-size: 9px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -457,8 +457,8 @@
|
||||
}
|
||||
|
||||
.b05-route-profile__trailing > .b05-drainage__toggle {
|
||||
padding: 1px var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
}
|
||||
|
||||
.b05-profile__zoom-btn {
|
||||
|
||||
@@ -601,8 +601,11 @@ export function computeCulvertLayout(
|
||||
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
|
||||
const slopeOf = (wall: WallLayout): FillSlopeSegment =>
|
||||
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
|
||||
// 유출 끝 연결선(139-4)이 있으면 벽 모서리 절토(91-4)를 또 잇지 않음 — 설계선이 접힘(186-1)
|
||||
const outletTail = drawnFillPoints(extrasForArea, true).length > 0;
|
||||
for (const wall of walls) {
|
||||
const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, !culvert.hidden_pipe);
|
||||
const backCut = !culvert.hidden_pipe && !outletTail;
|
||||
const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, backCut);
|
||||
// 사면선은 노견부터 우리가 그림 — 트림을 노견까지 당겨 백엔드 설계선 성토 구간을 통째로 덮음
|
||||
if (wall.outward > 0) {
|
||||
trimMax = slope.points[0].offset;
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 185-2 보기 단추 글 한 줄 */
|
||||
.b09-estimation__bar .ui-btn {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
|
||||
font-size: var(--text-body);
|
||||
@@ -112,3 +118,21 @@
|
||||
background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas));
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */
|
||||
.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child {
|
||||
padding-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent {
|
||||
padding-left: calc(var(--spacing-8) + 1em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
|
||||
padding-left: calc(var(--spacing-8) + 2em);
|
||||
}
|
||||
|
||||
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
@@ -227,16 +229,16 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const box = cell.getBoundingClientRect();
|
||||
openMenuAt(box.left, box.bottom, items);
|
||||
});
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3)
|
||||
if (spec.tip)
|
||||
made.root.addEventListener("mouseover", (event) => {
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
|
||||
const row = cell?.parentElement;
|
||||
const column = cell ? spec.columns[cell.cellIndex] : undefined;
|
||||
if (!cell || !row || !column) return;
|
||||
const tip = spec.tip?.(Number(row.dataset.i), column.key);
|
||||
if (tip) cell.title = tip;
|
||||
});
|
||||
attachHoverInfo(
|
||||
made.root,
|
||||
(cell) => {
|
||||
const column = spec.columns[(cell as HTMLTableCellElement).cellIndex];
|
||||
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
},
|
||||
"tr[data-i] td",
|
||||
);
|
||||
return made;
|
||||
};
|
||||
const hint = spec.hint ? note(spec.hint) : null;
|
||||
@@ -297,6 +299,21 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
|
||||
});
|
||||
refresh.dataset.b09Refresh = "";
|
||||
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
|
||||
const reset = createButton({
|
||||
label: "내역 초기화",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void showConfirmDialog(
|
||||
"설계내역서에서 고친 칸 · 더한 줄 · 지운 줄 · 차례를 모두 지우고 기본 로직 내역으로 되돌립니다. 바로 저장됩니다.",
|
||||
"초기화",
|
||||
).then((ok) => {
|
||||
if (!ok) return;
|
||||
const { bill: _bill, ...rest } = user;
|
||||
void run("/estimation", "PUT", "설계내역서를 초기값으로 되돌렸습니다.", { user: rest });
|
||||
}),
|
||||
});
|
||||
reset.dataset.b09BillReset = "";
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const heading = document.createElement("h3");
|
||||
@@ -305,7 +322,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
section.append(heading, made, refresh);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save);
|
||||
actions.append(save, reset);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-options";
|
||||
left.append(section, actions);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) ·
|
||||
* 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력
|
||||
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
|
||||
* · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음)
|
||||
* 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶)
|
||||
* · 출처 묶음 = 단가 칸 하나 · 출처 뜻(M01 면수 · 유가 기준일)은 단가 칸 호버(185-4 `sourceInfo`)
|
||||
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -125,10 +126,33 @@ function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMen
|
||||
];
|
||||
}
|
||||
|
||||
/** 출처 단가 칸 호버 글(185-4) — M01 면수 「670(26.7)」 · 유가 기준일 「2026-08-14」 를 뜻으로 풂 ·
|
||||
* 값 없는(0 · 빈칸) 칸 = 없음 · 값 · 저장 꼴은 그대로 */
|
||||
export function sourceInfo(r: Row, s: string, files = 0): string | null {
|
||||
if (!(Number(r.prices?.[s]) > 0)) return null;
|
||||
const page = String(r.pages?.[s] ?? "").trim();
|
||||
if (s === "관급") return "관급 · 조달청 계약 단가";
|
||||
if (s === "견적") return `견적 · 수기 입력${files ? ` · 견적서 ${files} 파일` : ""}`;
|
||||
if (s === "유가") {
|
||||
const from = String(r.source ?? "");
|
||||
if (!page) return "유가";
|
||||
if (from.includes("오피넷")) return `유가 · 오피넷 · ${page} 수집`;
|
||||
if (from.includes("한전")) return `전기요금 · 한전 · ${page} 시행`;
|
||||
return `유가 · ${page} 기준`;
|
||||
}
|
||||
// 면수 = [부(부록) · 하(하권) · n권] 쪽(연.월) — 예 「부536(26.7)」 = 2026-07 호 부록 536쪽
|
||||
const m = /^(부|하|\d+권)?(\d+)\((\d{2})\.(\d{1,2})\)$/.exec(page);
|
||||
if (!m) return page ? `${s} · ${page}` : `${s} · 면수 없음`;
|
||||
const book = m[1] === "부" ? "부록 " : m[1] === "하" ? "하권 " : m[1] ? `${m[1]} ` : "";
|
||||
return `${s} · 20${m[3]}-${m[4].padStart(2, "0")} · ${book}${m[2]}쪽`;
|
||||
}
|
||||
|
||||
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const rows: Row[] = sheets.compare || [];
|
||||
const out: DataRow[] = rows.map((r) => {
|
||||
const files: string[] = (user.materials || {})[r.key]?.files || [];
|
||||
// 185-5 견적서 글자 = 견적 단가 수기 줄만(올린 파일이 남은 줄도 · 나머지 빈칸)
|
||||
const quoted = r.prices["견적"] != null || files.length > 0;
|
||||
const row: DataRow = {
|
||||
no: r.no,
|
||||
key: r.key,
|
||||
@@ -139,12 +163,9 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
applied: r.applied,
|
||||
applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""),
|
||||
supply: r.supply ?? "",
|
||||
files: files.length ? `${files.length} 파일` : "첨부",
|
||||
files: !quoted ? "" : files.length ? `${files.length} 파일` : "첨부",
|
||||
};
|
||||
for (const s of SOURCES) {
|
||||
row[`p:${s}`] = r.prices[s];
|
||||
row[`g:${s}`] = r.pages[s] ?? "";
|
||||
}
|
||||
for (const s of SOURCES) row[`p:${s}`] = r.prices[s];
|
||||
return row;
|
||||
});
|
||||
const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) });
|
||||
@@ -160,11 +181,12 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
text("name", "품명", 180),
|
||||
text("spec", "규격", 140),
|
||||
text("unit", "단위", 56),
|
||||
// 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값)
|
||||
...SOURCES.flatMap((s) => [
|
||||
money(`p:${s}`, "단가", [s]),
|
||||
s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]),
|
||||
]),
|
||||
// 185-4 — 출처 = 단가 하나(출처 뜻은 단가 칸 호버) · 견적 = 단가 · 견적서
|
||||
...SOURCES.flatMap((s) =>
|
||||
s === "견적"
|
||||
? [money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]
|
||||
: [money(`p:${s}`, "단가", [s])],
|
||||
),
|
||||
money("lowest", "최저"),
|
||||
money("applied", "적용 단가", ["적용"]),
|
||||
text("applied_from", "출처", 80, ["적용"]),
|
||||
@@ -172,7 +194,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
],
|
||||
rows: out,
|
||||
frozen: 2,
|
||||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
|
||||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)",
|
||||
tip: (i, key) =>
|
||||
key.startsWith("p:") && rows[i]
|
||||
? sourceInfo(
|
||||
rows[i],
|
||||
key.slice(2),
|
||||
((user.materials || {})[rows[i].key]?.files || []).length,
|
||||
)
|
||||
: null,
|
||||
editable: (_i, key) => key === "p:견적",
|
||||
onEdit: (i, key, value) => {
|
||||
const row = out[i];
|
||||
@@ -201,7 +231,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const r = rows[i];
|
||||
if (!row || !r) return;
|
||||
const cell = mine(String(row.key));
|
||||
if (key === "files") {
|
||||
if (key === "files" && row.files) {
|
||||
const label = `${r.name} ${r.spec}`.trim();
|
||||
return openQuotes(r.key, label, (names) => {
|
||||
const now = mine(r.key);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill.ts
|
||||
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
* B09 설계내역서 탭(PLAN 145-3 · 184-3) — 실무 엑셀 꼴 칸(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
*
|
||||
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
|
||||
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 초기값 = 기본 로직이 B08 수량으로 짠 줄 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(184 · `…_UI_Tabs_Bill_Edit`)
|
||||
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
|
||||
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`)
|
||||
* 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침
|
||||
* 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -16,25 +16,32 @@ import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
billMenu,
|
||||
editQty,
|
||||
editText,
|
||||
handEditable,
|
||||
editCell,
|
||||
linkable,
|
||||
openLinkMenu,
|
||||
type BillTable,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
|
||||
export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const { sheets, user, setUser } = ctx;
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
const rows: DataRow[] = bill.rows.map((r: Row) => ({
|
||||
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
|
||||
const raw: DataRow[] = bill.rows;
|
||||
const rows: DataRow[] = raw.map((r: Row) => ({
|
||||
...r,
|
||||
connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "",
|
||||
name: r.no ? `${r.no} ${r.name}` : r.name,
|
||||
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "",
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`price_${key}`, "단가", [label]),
|
||||
money(key, "금액", [label]),
|
||||
];
|
||||
/** 고친 칸 — 셈 줄에 자동 값(auto_칸)이 있음 · 연결 칸 = auto_ref */
|
||||
const edited = (r: DataRow | undefined, key: string): boolean =>
|
||||
!!r && `auto_${key === "connect" ? "ref" : key}` in r;
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 명칭", 220),
|
||||
@@ -52,41 +59,45 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
total: { name: "순공사비 계(관급 제외)", ...direct },
|
||||
frozen: 1,
|
||||
hint:
|
||||
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
|
||||
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
|
||||
"레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " +
|
||||
"기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " +
|
||||
"금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " +
|
||||
"조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editable: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r?.kind === "group") return key === "name";
|
||||
if (!r || r.kind !== "item") return false;
|
||||
return r.hand ? handEditable(key) : key === "qty";
|
||||
if (!r) return false;
|
||||
if (r.key === "fee") return key === "qty";
|
||||
if (r.kind !== "item") return key === "name";
|
||||
return key === "qty" || TEXT_KEYS.includes(key);
|
||||
},
|
||||
onEdit: (i, key, value) => {
|
||||
const r = rows[i];
|
||||
if (!r) return;
|
||||
if (r.hand && key !== "qty") return editText(ctx, String(r.key), key, value);
|
||||
if (key !== "qty") return;
|
||||
// 번호 = 셈이 붙임 — 고친 이름 앞 번호는 뗌
|
||||
const head = `${r.no} `;
|
||||
if (key === "name" && r.no && value.startsWith(head)) value = value.slice(head.length);
|
||||
if (key !== "qty") return editCell(ctx, raw, i, key, value.trim());
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
if (r.key !== "fee") return editQty(ctx, r, v);
|
||||
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
|
||||
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
||||
},
|
||||
// 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게
|
||||
rowClass: (i) => {
|
||||
const r = rows[i];
|
||||
if (!r) return null;
|
||||
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
|
||||
return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" ");
|
||||
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
|
||||
},
|
||||
menu: (i) => billMenu(ctx, rows, i),
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
onCell: (i, key) => {
|
||||
if (key === "connect") openLinkMenu(ctx, rows, i);
|
||||
if (key === "connect") openLinkMenu(ctx, raw, i);
|
||||
},
|
||||
link: (i, key) => {
|
||||
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Edit.ts
|
||||
* B09 설계내역서 손질(PLAN 166-2) — 우클릭 메뉴 · 손 줄 더하기 · 지우기 · 연결 고르기 · 수량 덮어쓰기.
|
||||
* B09 설계내역서 손질(PLAN 166-2 · 182 · 184-3) — 모든 줄 우클릭 메뉴 · 칸 고침 · 연결 · 차례 · 레벨.
|
||||
*
|
||||
* 저장 = 사용자 값 `user.bill`(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
|
||||
* qty{줄 키: 수} = 자동 줄 수량 덮어쓰기(빈칸 · [수량 자동으로] = 키 지움 → B08 값)
|
||||
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
|
||||
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
|
||||
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
|
||||
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
|
||||
* 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
|
||||
* {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]}
|
||||
* 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천)
|
||||
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct
|
||||
* 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김)
|
||||
* 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level ·
|
||||
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
|
||||
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
|
||||
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -17,322 +19,300 @@ import {
|
||||
type MapContextMenu,
|
||||
type MapContextMenuItem,
|
||||
} from "@ui/ui_template_context_menu";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
|
||||
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
|
||||
const MAX_GROUP = 2;
|
||||
const NEW_SECTION = "새 공종";
|
||||
/** 고칠 칸 — 자동 줄은 덮개 */
|
||||
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
|
||||
/** 깊이 한도 — 1 = 맨 위 공종 */
|
||||
const MAX_LEVEL = 5;
|
||||
|
||||
interface Bill {
|
||||
qty: Record<string, number>;
|
||||
v: 2;
|
||||
rows: Row[];
|
||||
gone: string[];
|
||||
}
|
||||
|
||||
function billOf(user: Row): Bill {
|
||||
/** 손질 한 번 = 굳힌 덮개 + 보이는 줄(키 → 셈 출력 줄) */
|
||||
interface Work {
|
||||
bill: Bill;
|
||||
shown: Map<string, DataRow>;
|
||||
}
|
||||
|
||||
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
|
||||
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
|
||||
|
||||
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
|
||||
export const linkable = (row: DataRow | undefined): boolean =>
|
||||
row?.kind === "item" && !fixed(String(row.key)) && !String(row.key).startsWith("mat:");
|
||||
|
||||
const find = (list: Row[], key: string): number => list.findIndex((e) => e.key === key);
|
||||
const pull = (list: Row[], key: string): Row => list.splice(find(list, key), 1)[0];
|
||||
const same = (a: unknown, b: unknown): boolean =>
|
||||
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
/** 옛 꼴(166-2 · 182) 줄 → v2 칸 — 자동 줄 = 수량 덮어쓰기 · 손 줄 = 모든 칸 */
|
||||
function oldEntry(row: DataRow, bill: Row): Row {
|
||||
const key = String(row.key);
|
||||
if (row.auto) return key in (bill.qty || {}) ? { qty: bill.qty[key] } : {};
|
||||
const hand: Row = (bill.rows || []).find((h: Row) => h.key === key) || {};
|
||||
const out: Row = row.kind === "item" ? {} : { kind: "group" };
|
||||
for (const f of FIELDS) if (f in hand) out[f] = hand[f];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 굳힘 — v2 면 그대로 + 덮개에 없는 보이는 줄(새 자동 줄)을 보이는 앞 줄 뒤에 · 옛 꼴이면 보이는 줄로 */
|
||||
function freeze(user: Row, rows: DataRow[]): Work {
|
||||
const bill: Row = user.bill || {};
|
||||
return { qty: { ...(bill.qty || {}) }, rows: (bill.rows || []).map((r: Row) => ({ ...r })) };
|
||||
const v2 = bill.v === 2;
|
||||
const list: Row[] = v2 ? (bill.rows || []).map((e: Row) => ({ ...e })) : [];
|
||||
const gone: string[] = v2 ? [...(bill.gone || [])] : [];
|
||||
const shown = new Map(rows.map((r) => [String(r.key), r]));
|
||||
let prev = -1;
|
||||
for (const row of rows) {
|
||||
const key = String(row.key);
|
||||
if (key === "fee") continue; // 셈이 관급 묶음 끝에 둠
|
||||
let at = find(list, key);
|
||||
if (at < 0 && !gone.includes(key)) {
|
||||
const entry: Row = { key, ...(row.parent ? { parent: row.parent } : {}) };
|
||||
list.splice(prev + 1, 0, v2 ? entry : { ...entry, ...oldEntry(row, bill) });
|
||||
at = prev + 1;
|
||||
}
|
||||
if (at >= 0) prev = at;
|
||||
}
|
||||
return { bill: { v: 2, rows: list, gone }, shown };
|
||||
}
|
||||
|
||||
function put(ctx: TabContext, bill: Bill): void {
|
||||
ctx.setUser({ ...ctx.user, bill });
|
||||
}
|
||||
|
||||
/** 손 줄 고칠 칸 — 명칭 · 규격 · 단위 · 수량 */
|
||||
export const handEditable = (key: string): boolean => key === "qty" || TEXT_KEYS.includes(key);
|
||||
|
||||
/** 수량 고침 — 손 줄 = 그 줄 수량 · 자동 줄 = 덮어쓰기(빈칸 = 자동으로) */
|
||||
export function editQty(ctx: TabContext, row: DataRow, value: number | null): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const key = String(row.key);
|
||||
if (row.hand) {
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return;
|
||||
own.qty = value;
|
||||
} else if (value === null) delete bill.qty[key];
|
||||
else bill.qty[key] = value;
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 손 줄 글 칸(명칭 · 규격 · 단위) */
|
||||
export function editText(ctx: TabContext, key: string, field: string, value: string): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own || !TEXT_KEYS.includes(field)) return;
|
||||
own[field] = value.trim();
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
|
||||
|
||||
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
|
||||
function subtreeEnd(rows: DataRow[], i: number): number {
|
||||
let k = i + 1;
|
||||
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
|
||||
return k;
|
||||
}
|
||||
|
||||
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
|
||||
function reparent(h: Row, parent: unknown): void {
|
||||
if (parent) h.parent = parent;
|
||||
else delete h.parent;
|
||||
}
|
||||
|
||||
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
|
||||
function fits(bill: Bill): boolean {
|
||||
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
|
||||
return bill.rows.every((h) => {
|
||||
let n = 0;
|
||||
const seen = new Set([h.key]);
|
||||
let at = byKey.get(String(h.parent ?? ""));
|
||||
while (at && at.kind === "group" && !seen.has(at.key)) {
|
||||
seen.add(at.key);
|
||||
n += 1;
|
||||
at = byKey.get(String(at.parent ?? ""));
|
||||
}
|
||||
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
|
||||
});
|
||||
}
|
||||
|
||||
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
|
||||
function slot(rows: DataRow[], i: number, below: boolean): Row {
|
||||
const row = rows[i];
|
||||
const section = String(row.section);
|
||||
if (row.kind === "title") return { section, after: `sec:${section}` };
|
||||
const parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
|
||||
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
|
||||
const prev = rows[i - 1];
|
||||
const same = prev && prev.kind !== "title" && prev.section === section;
|
||||
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
|
||||
}
|
||||
|
||||
function newRow(ctx: TabContext, at: Row): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
|
||||
newRow(ctx, slot(rows, i, below));
|
||||
}
|
||||
|
||||
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
|
||||
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
|
||||
const after = String(rows[subtreeEnd(rows, i) - 1].key);
|
||||
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
|
||||
}
|
||||
|
||||
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
|
||||
function removeRow(ctx: TabContext, key: string): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const gone = bill.rows.find((h) => h.key === key);
|
||||
if (!gone) return;
|
||||
bill.rows = bill.rows.filter((h) => h.key !== key);
|
||||
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
|
||||
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
|
||||
function levelAction(
|
||||
ctx: TabContext,
|
||||
key: string,
|
||||
change: (own: Row, bill: Bill) => void,
|
||||
): (() => void) | null {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return null;
|
||||
change(own, bill);
|
||||
return fits(bill) ? () => put(ctx, bill) : null;
|
||||
}
|
||||
|
||||
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
|
||||
function indentTarget(rows: DataRow[], i: number): DataRow | null {
|
||||
const row = rows[i];
|
||||
for (let j = i - 1; j >= 0; j -= 1) {
|
||||
const r = rows[j];
|
||||
if (lv(r) > lv(row)) continue;
|
||||
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
|
||||
/** 위로 올라가는 줄 — [자기, 부모, …, 맨 위] */
|
||||
function chain(list: Row[], key: string): Row[] {
|
||||
const out: Row[] = [];
|
||||
let at: Row | undefined = list[find(list, key)];
|
||||
while (at && out.length <= list.length) {
|
||||
out.push(at);
|
||||
at = at.parent ? list[find(list, at.parent)] : undefined;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
|
||||
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
const key = String(row.key);
|
||||
const out: MapContextMenuItem[] = [];
|
||||
const add = (label: string, action: (() => void) | null): void => {
|
||||
if (action) out.push([label, action]);
|
||||
};
|
||||
if (row.kind === "group")
|
||||
add(
|
||||
"항목으로 바꾸기",
|
||||
levelAction(ctx, key, (own, bill) => {
|
||||
delete own.kind;
|
||||
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
|
||||
}),
|
||||
);
|
||||
else
|
||||
add(
|
||||
"묶음 줄로 바꾸기",
|
||||
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
|
||||
);
|
||||
const target = indentTarget(rows, i);
|
||||
if (target)
|
||||
add(
|
||||
"들여쓰기 →",
|
||||
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
|
||||
);
|
||||
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
|
||||
add(
|
||||
"내어쓰기 ←",
|
||||
levelAction(ctx, key, (own, bill) =>
|
||||
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
||||
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return;
|
||||
own.ref = ref;
|
||||
if (from && (!own.name || own.name === NEW_NAME)) {
|
||||
own.name = from.name || own.name;
|
||||
own.spec = own.spec || from.spec || "";
|
||||
own.unit = own.unit || from.unit || "";
|
||||
}
|
||||
put(ctx, bill);
|
||||
/** 차례 바로잡음 — 부모 먼저 · 자손 이어서(목록 차례 = 형제 차례) · 못 찾는 부모 · 돎 = 맨 위 */
|
||||
function tidy(list: Row[]): Row[] {
|
||||
const keys = new Set(list.map((e) => e.key));
|
||||
for (const e of list) if (!e.parent || !keys.has(e.parent)) delete e.parent;
|
||||
for (const e of list) if (chain(list, e.key).length > list.length) delete e.parent;
|
||||
const out: Row[] = [];
|
||||
// ponytail: 형제 찾기 O(n²) — 내역 줄 수(수백)면 충분
|
||||
const walk = (parent?: string): void => {
|
||||
for (const e of list)
|
||||
if (e.parent === parent) {
|
||||
out.push(e);
|
||||
walk(e.key);
|
||||
}
|
||||
};
|
||||
walk();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
|
||||
function pickHopyo(ctx: TabContext, key: string, tab: "dg" | "sg"): void {
|
||||
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
|
||||
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
|
||||
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
|
||||
openModal({
|
||||
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
|
||||
closeLabel: "닫기",
|
||||
mount: (body, close) => {
|
||||
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
const found = listing.filter(
|
||||
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
|
||||
);
|
||||
finder.hint.textContent = `${found.length} / ${listing.length}`;
|
||||
finder.setRows(
|
||||
found.map((r) => ({
|
||||
key: String(r.key),
|
||||
tag: label(r),
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => {
|
||||
close();
|
||||
setRef(ctx, key, { tab, key: r.key }, r);
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
finder.search.addEventListener("input", find);
|
||||
find();
|
||||
body.append(finder.root);
|
||||
requestAnimationFrame(() => finder.search.focus());
|
||||
},
|
||||
});
|
||||
/** 맨 위 묶음 돈 갈래 — direct(직접비) | supply(관급자재대) */
|
||||
function costOf(w: Work, list: Row[], key: string): string {
|
||||
const top = chain(list, key).at(-1);
|
||||
return top?.cost ?? w.shown.get(String(top?.key))?.cost ?? "direct";
|
||||
}
|
||||
|
||||
/** M01 로직 → 입력값 창 → 손 줄 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
|
||||
function askInputs(ctx: TabContext, key: string, code: string, name: string, old?: Row): void {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
const logic = got.로직[0] ?? null;
|
||||
openLaborInput({
|
||||
title: `${code} ${name}`.trim(),
|
||||
logic,
|
||||
values: old?.given || {},
|
||||
memo: null,
|
||||
rate: old?.rate ?? null,
|
||||
readOnly: false,
|
||||
okLabel: "연결",
|
||||
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
|
||||
done: (next) => {
|
||||
const unit = String(logic?.결과단위 || "")
|
||||
.replace(/^원\//, "")
|
||||
.replace(/^[\d.]+/, "");
|
||||
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
|
||||
setRef(ctx, key, { ...ref, picks: old?.picks ?? null }, { name, unit });
|
||||
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
|
||||
},
|
||||
});
|
||||
},
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
/** 바꾼 뒤 지킬 것 — 깊이 5 · 자재 줄 돈 갈래 그대로 */
|
||||
function fits(w: Work, before: Row[]): boolean {
|
||||
return w.bill.rows.every(
|
||||
(e) =>
|
||||
chain(w.bill.rows, e.key).length <= MAX_LEVEL &&
|
||||
(!String(e.key).startsWith("mat:") ||
|
||||
costOf(w, w.bill.rows, e.key) === costOf(w, before, e.key)),
|
||||
);
|
||||
}
|
||||
|
||||
function pickLogic(ctx: TabContext, key: string): void {
|
||||
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
|
||||
/** 손질 시도 — 지킬 것 안이면 [동작] · 아니면 null(메뉴에 안 보임) */
|
||||
function attempt(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
change: (w: Work) => boolean | void,
|
||||
): (() => void) | null {
|
||||
const w = freeze(ctx.user, rows);
|
||||
const before = w.bill.rows.map((e) => ({ ...e }));
|
||||
if (change(w) === false) return null;
|
||||
w.bill.rows = tidy(w.bill.rows);
|
||||
return fits(w, before) ? () => put(ctx, w.bill) : null;
|
||||
}
|
||||
|
||||
type Leaf = [string, () => void];
|
||||
const entryOf = (w: Work, key: string): Row | undefined => w.bill.rows[find(w.bill.rows, key)];
|
||||
const isGroup = (w: Work, key: string): boolean =>
|
||||
(entryOf(w, key)?.kind ?? w.shown.get(key)?.kind ?? "item") !== "item";
|
||||
/** 보이는 형제(차례대로) */
|
||||
const siblings = (w: Work, parent: unknown): Row[] =>
|
||||
w.bill.rows.filter((e) => (e.parent ?? null) === (parent ?? null) && w.shown.has(e.key));
|
||||
|
||||
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
|
||||
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
|
||||
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
|
||||
const more: Leaf[] = [];
|
||||
if (ref?.tab === "logic")
|
||||
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
|
||||
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
|
||||
];
|
||||
return [picks, more];
|
||||
const newKey = (): string =>
|
||||
`hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const newItem = (parent?: string): Row => ({
|
||||
key: newKey(),
|
||||
...(parent ? { parent } : {}),
|
||||
name: NEW_NAME,
|
||||
spec: "",
|
||||
unit: "",
|
||||
qty: null,
|
||||
ref: null,
|
||||
});
|
||||
|
||||
/** 칸 고침 — 자동 줄은 덮개(자동 값과 같으면 덮개 지움) · 새 줄은 그 칸 */
|
||||
export function editCell(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
i: number,
|
||||
field: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const row = rows[i];
|
||||
if (!row || !FIELDS.includes(field)) return;
|
||||
const w = freeze(ctx.user, rows);
|
||||
const e = entryOf(w, String(row.key));
|
||||
if (!e) return;
|
||||
const auto = `auto_${field}` in row ? row[`auto_${field}`] : row[field];
|
||||
if (row.auto && same(auto, value)) delete e[field];
|
||||
else e[field] = value;
|
||||
put(ctx, w.bill);
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
|
||||
/** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
||||
function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
|
||||
return (ref, from) => {
|
||||
const row = rows[i];
|
||||
const w = freeze(ctx.user, rows);
|
||||
const e = entryOf(w, String(row?.key));
|
||||
if (!row || !e) return;
|
||||
e.ref = ref;
|
||||
if (from && !row.auto && (!e.name || e.name === NEW_NAME)) {
|
||||
e.name = from.name || e.name;
|
||||
e.spec = e.spec || from.spec || "";
|
||||
e.unit = e.unit || from.unit || "";
|
||||
}
|
||||
put(ctx, w.bill);
|
||||
};
|
||||
}
|
||||
|
||||
/** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */
|
||||
function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
|
||||
const row = rows[i];
|
||||
const e = entryOf(freeze(ctx.user, rows), String(row.key));
|
||||
const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null);
|
||||
return linkItems(ctx, ref, applyRef(ctx, rows, i));
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
|
||||
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
if (!row) return [];
|
||||
const key = String(row.key);
|
||||
const bill = billOf(ctx.user);
|
||||
const items: MapContextMenuItem[] = [];
|
||||
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
|
||||
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
|
||||
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
|
||||
if (row.hand) {
|
||||
if (row.kind !== "group") {
|
||||
const [picks, more] = linkItems(ctx, key);
|
||||
items.push("-", { label: "연결", children: picks }, ...more);
|
||||
}
|
||||
items.push("-", ...levelMenu(ctx, rows, i));
|
||||
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
|
||||
} else if (row.kind === "item" && key in bill.qty) {
|
||||
items.push("-", [
|
||||
"수량 자동으로 되돌리기",
|
||||
() => {
|
||||
delete bill.qty[key];
|
||||
put(ctx, bill);
|
||||
},
|
||||
]);
|
||||
const parts: MapContextMenuItem[][] = [[], [], [], []];
|
||||
const add = (part: number, label: string, change: (w: Work, e: Row) => boolean | void): void => {
|
||||
const run = attempt(ctx, rows, (w) => {
|
||||
// 조달수수료 = 차례에 없음(셈이 관급자재대 끝에 둠) — 부모만 빌림
|
||||
const e = entryOf(w, key) ?? (key === "fee" ? { key, parent: row.parent } : undefined);
|
||||
return e ? change(w, e) : false;
|
||||
});
|
||||
if (run) parts[part].push([label, run]);
|
||||
};
|
||||
|
||||
// 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞)
|
||||
add(0, "위에 줄 추가", (w, e) => {
|
||||
if (!e.parent) return false;
|
||||
const at = find(w.bill.rows, key);
|
||||
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent));
|
||||
});
|
||||
add(0, "아래에 줄 추가", (w, e) => {
|
||||
if (!e.parent || key === "fee") return false;
|
||||
w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent));
|
||||
});
|
||||
add(0, "아래에 하위 줄 추가", (w) => {
|
||||
if (!isGroup(w, key)) return false;
|
||||
w.bill.rows.push(newItem(key));
|
||||
});
|
||||
add(0, "새 공종 추가(1단)", (w, e) => {
|
||||
const list = w.bill.rows;
|
||||
const from = find(list, key) < 0 ? String(e.parent) : key;
|
||||
const top = chain(list, from).at(-1)!;
|
||||
const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1);
|
||||
list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION });
|
||||
});
|
||||
if (linkable(row)) {
|
||||
const [picks, more] = linkFor(ctx, rows, i);
|
||||
parts[1].push({ label: "연결", children: picks }, ...more);
|
||||
}
|
||||
return items;
|
||||
// 옮김 · 레벨 — 같은 부모 형제와 자손째
|
||||
if (!fixed(key)) {
|
||||
add(2, "위로 ↑", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const k = sib.indexOf(e);
|
||||
if (k < 1) return false;
|
||||
pull(w.bill.rows, key);
|
||||
w.bill.rows.splice(find(w.bill.rows, sib[k - 1].key), 0, e);
|
||||
});
|
||||
add(2, "아래로 ↓", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const k = sib.indexOf(e);
|
||||
if (k < 0 || k + 1 >= sib.length) return false;
|
||||
pull(w.bill.rows, key);
|
||||
w.bill.rows.splice(find(w.bill.rows, sib[k + 1].key) + 1, 0, e);
|
||||
});
|
||||
add(2, "들여쓰기 →", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const up = sib[sib.indexOf(e) - 1];
|
||||
if (!up || !isGroup(w, up.key)) return false;
|
||||
e.parent = up.key;
|
||||
w.bill.rows.push(pull(w.bill.rows, key));
|
||||
});
|
||||
add(2, "내어쓰기 ←", (w, e) => {
|
||||
const up = e.parent ? entryOf(w, e.parent) : undefined;
|
||||
if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴)
|
||||
e.parent = up.parent;
|
||||
pull(w.bill.rows, key);
|
||||
w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e);
|
||||
});
|
||||
}
|
||||
if (!row.auto && row.kind === "item")
|
||||
add(2, "묶음 줄로 바꾸기", (_w, e) => {
|
||||
Object.assign(e, { kind: "group", qty: null, ref: null });
|
||||
});
|
||||
if (!row.auto && row.kind !== "item")
|
||||
add(2, "항목으로 바꾸기", (w, e) => {
|
||||
if (!e.parent) return false;
|
||||
delete e.kind;
|
||||
for (const c of w.bill.rows) if (c.parent === key) c.parent = e.parent;
|
||||
});
|
||||
// 자동값으로 — 그 줄 고친 칸 지움(차례 · 부모는 그대로)
|
||||
if (row.auto && FIELDS.some((f) => `auto_${f}` in row))
|
||||
add(3, "자동값으로 되돌리기", (_w, e) => {
|
||||
for (const f of FIELDS) delete e[f];
|
||||
});
|
||||
// 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움
|
||||
if (!fixed(key) && !key.startsWith("mat:"))
|
||||
add(3, "줄 지우기", (w, e) => {
|
||||
const list = w.bill.rows;
|
||||
if (!e.parent && list.some((c) => c.parent === key)) return false;
|
||||
for (const c of list) if (c.parent === key) c.parent = e.parent;
|
||||
pull(list, key);
|
||||
if (row.auto) w.bill.gone.push(key);
|
||||
});
|
||||
const out: MapContextMenuItem[] = [];
|
||||
for (const part of parts)
|
||||
if (part.length) out.push(...(out.length ? ["-" as const] : []), ...part);
|
||||
return out;
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
@@ -341,10 +321,9 @@ let lastCell: Element | null = null;
|
||||
|
||||
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
|
||||
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
|
||||
const row = rows[i];
|
||||
const box = lastCell?.getBoundingClientRect();
|
||||
if (!shared || !box || !row?.hand || row.kind !== "item") return;
|
||||
const [picks, more] = linkItems(ctx, String(row.key));
|
||||
if (!shared || !box || !linkable(rows[i])) return;
|
||||
const [picks, more] = linkFor(ctx, rows, i);
|
||||
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Link.ts
|
||||
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
|
||||
* M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기.
|
||||
*
|
||||
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
|
||||
* 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
/** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */
|
||||
export type ApplyRef = (ref: Row | null, from?: Row) => void;
|
||||
export type Leaf = [string, () => void];
|
||||
|
||||
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
|
||||
function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void {
|
||||
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
|
||||
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
|
||||
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
|
||||
openModal({
|
||||
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
|
||||
closeLabel: "닫기",
|
||||
mount: (body, close) => {
|
||||
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
const found = listing.filter(
|
||||
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
|
||||
);
|
||||
finder.hint.textContent = `${found.length} / ${listing.length}`;
|
||||
finder.setRows(
|
||||
found.map((r) => ({
|
||||
key: String(r.key),
|
||||
tag: label(r),
|
||||
name: String(r.name || ""),
|
||||
sub: String(r.spec || ""),
|
||||
unit: String(r.unit || ""),
|
||||
onPick: () => {
|
||||
close();
|
||||
apply({ tab, key: r.key }, r);
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
finder.search.addEventListener("input", find);
|
||||
find();
|
||||
body.append(finder.root);
|
||||
requestAnimationFrame(() => finder.search.focus());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
|
||||
function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
const logic = got.로직[0] ?? null;
|
||||
openLaborInput({
|
||||
title: `${code} ${name}`.trim(),
|
||||
logic,
|
||||
values: old?.given || {},
|
||||
memo: null,
|
||||
rate: old?.rate ?? null,
|
||||
readOnly: false,
|
||||
okLabel: "연결",
|
||||
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
|
||||
done: (next) => {
|
||||
const unit = String(logic?.결과단위 || "")
|
||||
.replace(/^원\//, "")
|
||||
.replace(/^[\d.]+/, "");
|
||||
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
|
||||
apply({ ...ref, picks: old?.picks ?? null }, { name, unit });
|
||||
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
|
||||
},
|
||||
});
|
||||
},
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
);
|
||||
}
|
||||
|
||||
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
|
||||
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
if (ref?.tab === "logic")
|
||||
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["연결 풀기", () => apply(null)]);
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
|
||||
[
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
|
||||
],
|
||||
];
|
||||
return [picks, more];
|
||||
}
|
||||
@@ -177,7 +177,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
|
||||
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
|
||||
const chosen = new Map<TabId, GroupView>();
|
||||
|
||||
/** 묶음 탭 — 머리(안내 + 보기 단추) · 고른 보기(카드 · 표) */
|
||||
/** 묶음 탭 — 카드 = 안내 + 카드 판(보기 단추는 가로 열 수 줄) · 표 = 머리(안내 + 보기 단추) + 표 */
|
||||
export function renderGroupPanel(o: {
|
||||
tab: TabId;
|
||||
spec: TabTable;
|
||||
@@ -199,9 +199,6 @@ export function renderGroupPanel(o: {
|
||||
},
|
||||
});
|
||||
toggle.dataset.b09GroupView = view;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-estimation__bar";
|
||||
bar.append(...(o.hint ? [o.hint] : []), toggle);
|
||||
const at = o.focus === undefined ? -1 : rowIndex(o.spec.rows, o.focus);
|
||||
if (view === "cards") {
|
||||
const first = o.spec.columns[0]?.key ?? "";
|
||||
@@ -213,10 +210,14 @@ export function renderGroupPanel(o: {
|
||||
focus: at,
|
||||
money: numberFormat(o.spec, "sum"),
|
||||
qty: numberFormat(o.spec, "qty"),
|
||||
actions: [toggle], // 185-2 가로 열 수 단추와 한 줄
|
||||
});
|
||||
cards.dataset.b09Table = o.tab;
|
||||
return [bar, cards];
|
||||
return [...(o.hint ? [o.hint] : []), cards];
|
||||
}
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-estimation__bar";
|
||||
bar.append(...(o.hint ? [o.hint] : []), toggle);
|
||||
const range = pickRange(o.spec.rows, o.focus);
|
||||
const table = o.makeTable((i) => pickClass(range, i));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
|
||||
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
|
||||
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
|
||||
* 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 ·
|
||||
* 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title)
|
||||
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -74,6 +77,14 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
|
||||
ctx.setUser({ ...ctx.user, estimate });
|
||||
}
|
||||
|
||||
/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */
|
||||
const levelClass = (r: DataRow | undefined): string | null =>
|
||||
r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null;
|
||||
const indentClass =
|
||||
(first: string, name: string) =>
|
||||
(_i: number, key: string): string | null =>
|
||||
key === name ? "is-indent" : key === first ? "is-flat" : null;
|
||||
|
||||
function rateText(formula: string, rate: Num): string {
|
||||
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
|
||||
}
|
||||
@@ -113,6 +124,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
rate: null,
|
||||
hand: null,
|
||||
candidate: "",
|
||||
level: part.endsWith(".") ? 1 : 2,
|
||||
...extra,
|
||||
});
|
||||
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
|
||||
@@ -197,6 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
cellClass: indentClass("part", "name"),
|
||||
hint:
|
||||
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
|
||||
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
|
||||
@@ -262,6 +276,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
share: share[key] ?? null,
|
||||
note: formula[key] ?? "",
|
||||
link: link ?? "",
|
||||
level: !part ? 1 : name === "소계" ? 2 : 3,
|
||||
}));
|
||||
return {
|
||||
columns: [
|
||||
@@ -274,6 +289,8 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 3,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
cellClass: indentClass("group", "name"),
|
||||
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
{
|
||||
"source": "검증_랩탑메인(226fc805) route 282 · No.14+12.22 관(파형강관 D800 · 유입 L형 집수정 · 유출 흙막이 돌쌓기(찰) H1.3) · 저장 design 그대로(유출 d 9.0)",
|
||||
"cross_sections": [
|
||||
{
|
||||
"station_id": "station_000000292220",
|
||||
"chainage_m": 292.22,
|
||||
"label": "STA.0+292.220",
|
||||
"kind": "irregular",
|
||||
"center_x": 208535.186869,
|
||||
"center_y": 367765.719131,
|
||||
"center_z": 860.254154,
|
||||
"azimuth_deg": 134.945611,
|
||||
"plan_radius_m": null,
|
||||
"curve_outer_side": "left",
|
||||
"curve_widening_m": 2.033,
|
||||
"uphill_side": "right",
|
||||
"structure": "파형강관 D800",
|
||||
"samples": [
|
||||
{
|
||||
"offset_m": -20.0,
|
||||
"elevation_m": 875.942681,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -19.5,
|
||||
"elevation_m": 875.563445,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -19.0,
|
||||
"elevation_m": 875.223804,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -18.5,
|
||||
"elevation_m": 874.839366,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -18.0,
|
||||
"elevation_m": 874.373517,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -17.5,
|
||||
"elevation_m": 873.834699,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -17.0,
|
||||
"elevation_m": 873.328076,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -16.5,
|
||||
"elevation_m": 873.003181,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -16.0,
|
||||
"elevation_m": 872.868685,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -15.5,
|
||||
"elevation_m": 872.700062,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -15.0,
|
||||
"elevation_m": 872.492793,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -14.5,
|
||||
"elevation_m": 872.264882,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -14.0,
|
||||
"elevation_m": 871.921377,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -13.5,
|
||||
"elevation_m": 871.449263,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -13.0,
|
||||
"elevation_m": 871.050131,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -12.5,
|
||||
"elevation_m": 870.664549,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -12.0,
|
||||
"elevation_m": 870.348929,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -11.5,
|
||||
"elevation_m": 869.897268,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -11.0,
|
||||
"elevation_m": 869.481532,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -10.5,
|
||||
"elevation_m": 869.031119,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -10.0,
|
||||
"elevation_m": 868.558707,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -9.5,
|
||||
"elevation_m": 868.123739,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -9.0,
|
||||
"elevation_m": 867.798147,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -8.5,
|
||||
"elevation_m": 867.489499,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -8.0,
|
||||
"elevation_m": 867.256843,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -7.5,
|
||||
"elevation_m": 867.081439,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -7.0,
|
||||
"elevation_m": 866.940587,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -6.5,
|
||||
"elevation_m": 866.550692,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -6.0,
|
||||
"elevation_m": 865.812117,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -5.5,
|
||||
"elevation_m": 864.812694,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -5.0,
|
||||
"elevation_m": 864.211627,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -4.5,
|
||||
"elevation_m": 863.993389,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -4.0,
|
||||
"elevation_m": 863.791331,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -3.5,
|
||||
"elevation_m": 863.6089,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -3.0,
|
||||
"elevation_m": 863.47762,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -2.5,
|
||||
"elevation_m": 863.178131,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -2.0,
|
||||
"elevation_m": 862.70616,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -1.5,
|
||||
"elevation_m": 862.084857,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -1.0,
|
||||
"elevation_m": 861.397999,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": -0.5,
|
||||
"elevation_m": 860.781491,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 0.0,
|
||||
"elevation_m": 860.254154,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 0.5,
|
||||
"elevation_m": 860.008424,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 1.0,
|
||||
"elevation_m": 859.743024,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 1.5,
|
||||
"elevation_m": 859.431951,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 2.0,
|
||||
"elevation_m": 859.169405,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 2.5,
|
||||
"elevation_m": 858.865535,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 3.0,
|
||||
"elevation_m": 858.524512,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 3.5,
|
||||
"elevation_m": 858.164126,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 4.0,
|
||||
"elevation_m": 857.818061,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 4.5,
|
||||
"elevation_m": 857.442262,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 5.0,
|
||||
"elevation_m": 857.021062,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 5.5,
|
||||
"elevation_m": 856.664537,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 6.0,
|
||||
"elevation_m": 856.388529,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 6.5,
|
||||
"elevation_m": 856.164636,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 7.0,
|
||||
"elevation_m": 855.840671,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 7.5,
|
||||
"elevation_m": 855.415507,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 8.0,
|
||||
"elevation_m": 855.15202,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 8.5,
|
||||
"elevation_m": 854.88336,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 9.0,
|
||||
"elevation_m": 854.52968,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 9.5,
|
||||
"elevation_m": 854.210706,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 10.0,
|
||||
"elevation_m": 853.936088,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 10.5,
|
||||
"elevation_m": 853.667609,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 11.0,
|
||||
"elevation_m": 853.315257,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 11.5,
|
||||
"elevation_m": 852.962466,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 12.0,
|
||||
"elevation_m": 852.592563,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 12.5,
|
||||
"elevation_m": 852.365461,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 13.0,
|
||||
"elevation_m": 852.241766,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 13.5,
|
||||
"elevation_m": 852.168603,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 14.0,
|
||||
"elevation_m": 852.137894,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 14.5,
|
||||
"elevation_m": 852.122192,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 15.0,
|
||||
"elevation_m": 852.082728,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 15.5,
|
||||
"elevation_m": 851.581695,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 16.0,
|
||||
"elevation_m": 850.702327,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 16.5,
|
||||
"elevation_m": 849.751282,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 17.0,
|
||||
"elevation_m": 849.271151,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 17.5,
|
||||
"elevation_m": 849.023418,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 18.0,
|
||||
"elevation_m": 848.775996,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 18.5,
|
||||
"elevation_m": 848.584444,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 19.0,
|
||||
"elevation_m": 848.552501,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 19.5,
|
||||
"elevation_m": 848.524788,
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"offset_m": 20.0,
|
||||
"elevation_m": 848.338541,
|
||||
"valid": true
|
||||
}
|
||||
],
|
||||
"culvert": {
|
||||
"type": "pipe",
|
||||
"pipe_kind": "파형강관",
|
||||
"diameter_m": 0.8,
|
||||
"min_cover_m": 0.5,
|
||||
"foundation": null,
|
||||
"inlet": {
|
||||
"role": "inlet",
|
||||
"structure": "집수정",
|
||||
"basin_length_m": 2.0,
|
||||
"basin_before_m": 1.0,
|
||||
"basin_after_m": 1.0,
|
||||
"basin_height_m": 1.3,
|
||||
"basin_form": "L"
|
||||
},
|
||||
"outlet": {
|
||||
"role": "outlet",
|
||||
"structure": "기슭막이",
|
||||
"revet_form": "돌쌓기(찰)",
|
||||
"revet_height_m": 1.3,
|
||||
"revet_length_m": 10.0,
|
||||
"revet_before_m": 5.0,
|
||||
"revet_after_m": 5.0,
|
||||
"face_slope": 0.3
|
||||
},
|
||||
"chainage_m": 292.22
|
||||
},
|
||||
"design": {
|
||||
"ground_type": "ripping_rock",
|
||||
"geometry_preset": "rock",
|
||||
"section_mode": "both_cut",
|
||||
"ditch_side": "right",
|
||||
"ditch_type": "standard",
|
||||
"cut_slope_ratio": 0.4,
|
||||
"soil_cut_slope_ratio": 1.0,
|
||||
"two_stage_slope": true,
|
||||
"fill_slope_ratio": 1.2,
|
||||
"roadbed_width_m": 6.0,
|
||||
"carriageway_width_m": 5.0,
|
||||
"carriageway_standard_width_m": 3.0,
|
||||
"widening_left_m": 2.0,
|
||||
"widening_right_m": 0.0,
|
||||
"cross_slope_pct": 3.0,
|
||||
"ditch": {
|
||||
"type": "standard",
|
||||
"top_width_m": 0.9,
|
||||
"bottom_width_m": 0.3,
|
||||
"depth_m": 0.3
|
||||
},
|
||||
"ditch_enabled": true,
|
||||
"ditch_choice": null,
|
||||
"paved": false,
|
||||
"road_edges": {
|
||||
"left": {
|
||||
"offset_m": 4.0,
|
||||
"elevation_m": 857.4906
|
||||
},
|
||||
"right": {
|
||||
"offset_m": -2.0,
|
||||
"elevation_m": 857.3106
|
||||
}
|
||||
},
|
||||
"carriageway_edges": {
|
||||
"left": {
|
||||
"offset_m": 3.5,
|
||||
"elevation_m": 857.4756
|
||||
},
|
||||
"right": {
|
||||
"offset_m": -1.5,
|
||||
"elevation_m": 857.3256
|
||||
}
|
||||
},
|
||||
"design_elevation_m": 857.3706,
|
||||
"cut_area_m2": 27.9382,
|
||||
"cut_soil_area_m2": 8.3598,
|
||||
"cut_rock_area_m2": 19.5784,
|
||||
"cut_rock_kind": "ripping_rock",
|
||||
"fill_area_m2": 0.3364,
|
||||
"spoil_fill_area_m2": 0.0,
|
||||
"spoil_fill_side": null,
|
||||
"spoil_fill_width_m": 0.0,
|
||||
"spoil_fill_max_width_m": 0.0,
|
||||
"spoil_fill_line": [],
|
||||
"spoil_fill_unclosed": false,
|
||||
"spoil_fill_replaced_fill_m2": 0.0,
|
||||
"bench_cut_length_m": 0.0,
|
||||
"slope_unclosed": false,
|
||||
"fill_ground_slope": null,
|
||||
"ditch_area_m2": 0.18,
|
||||
"ditch_soil_area_m2": 0.0,
|
||||
"ditch_rock_area_m2": 0.18,
|
||||
"ditch_split_basis": "rock_boundary",
|
||||
"design_line": [
|
||||
{
|
||||
"offset_m": -20.0,
|
||||
"elevation_m": 875.9427
|
||||
},
|
||||
{
|
||||
"offset_m": -19.5,
|
||||
"elevation_m": 875.5634
|
||||
},
|
||||
{
|
||||
"offset_m": -19.0,
|
||||
"elevation_m": 875.2238
|
||||
},
|
||||
{
|
||||
"offset_m": -18.5,
|
||||
"elevation_m": 874.8394
|
||||
},
|
||||
{
|
||||
"offset_m": -18.0,
|
||||
"elevation_m": 874.3735
|
||||
},
|
||||
{
|
||||
"offset_m": -17.5,
|
||||
"elevation_m": 873.8347
|
||||
},
|
||||
{
|
||||
"offset_m": -17.0,
|
||||
"elevation_m": 873.3281
|
||||
},
|
||||
{
|
||||
"offset_m": -16.5,
|
||||
"elevation_m": 873.0032
|
||||
},
|
||||
{
|
||||
"offset_m": -16.0,
|
||||
"elevation_m": 872.8687
|
||||
},
|
||||
{
|
||||
"offset_m": -15.5,
|
||||
"elevation_m": 872.7001
|
||||
},
|
||||
{
|
||||
"offset_m": -15.0,
|
||||
"elevation_m": 872.4928
|
||||
},
|
||||
{
|
||||
"offset_m": -14.5,
|
||||
"elevation_m": 872.2649
|
||||
},
|
||||
{
|
||||
"offset_m": -14.0,
|
||||
"elevation_m": 871.9214
|
||||
},
|
||||
{
|
||||
"offset_m": -13.5,
|
||||
"elevation_m": 871.4493
|
||||
},
|
||||
{
|
||||
"offset_m": -13.0,
|
||||
"elevation_m": 871.0501
|
||||
},
|
||||
{
|
||||
"offset_m": -12.5,
|
||||
"elevation_m": 870.6645
|
||||
},
|
||||
{
|
||||
"offset_m": -12.0,
|
||||
"elevation_m": 870.3489
|
||||
},
|
||||
{
|
||||
"offset_m": -11.5,
|
||||
"elevation_m": 869.8973
|
||||
},
|
||||
{
|
||||
"offset_m": -11.0,
|
||||
"elevation_m": 869.4815
|
||||
},
|
||||
{
|
||||
"offset_m": -10.5,
|
||||
"elevation_m": 869.0311
|
||||
},
|
||||
{
|
||||
"offset_m": -10.0,
|
||||
"elevation_m": 868.5587
|
||||
},
|
||||
{
|
||||
"offset_m": -9.5,
|
||||
"elevation_m": 868.1237
|
||||
},
|
||||
{
|
||||
"offset_m": -9.0,
|
||||
"elevation_m": 867.7981
|
||||
},
|
||||
{
|
||||
"offset_m": -8.5,
|
||||
"elevation_m": 867.4895
|
||||
},
|
||||
{
|
||||
"offset_m": -8.0,
|
||||
"elevation_m": 867.2568
|
||||
},
|
||||
{
|
||||
"offset_m": -7.75,
|
||||
"elevation_m": 867.1691
|
||||
},
|
||||
{
|
||||
"offset_m": -7.5,
|
||||
"elevation_m": 866.9294
|
||||
},
|
||||
{
|
||||
"offset_m": -7.0,
|
||||
"elevation_m": 866.4294
|
||||
},
|
||||
{
|
||||
"offset_m": -6.5,
|
||||
"elevation_m": 865.9294
|
||||
},
|
||||
{
|
||||
"offset_m": -6.2459,
|
||||
"elevation_m": 865.6753
|
||||
},
|
||||
{
|
||||
"offset_m": -6.0,
|
||||
"elevation_m": 865.0606
|
||||
},
|
||||
{
|
||||
"offset_m": -5.5,
|
||||
"elevation_m": 863.8106
|
||||
},
|
||||
{
|
||||
"offset_m": -5.0,
|
||||
"elevation_m": 862.5606
|
||||
},
|
||||
{
|
||||
"offset_m": -4.5,
|
||||
"elevation_m": 861.3106
|
||||
},
|
||||
{
|
||||
"offset_m": -4.0,
|
||||
"elevation_m": 860.0606
|
||||
},
|
||||
{
|
||||
"offset_m": -3.5,
|
||||
"elevation_m": 858.8106
|
||||
},
|
||||
{
|
||||
"offset_m": -3.0,
|
||||
"elevation_m": 857.5606
|
||||
},
|
||||
{
|
||||
"offset_m": -2.9,
|
||||
"elevation_m": 857.3106
|
||||
},
|
||||
{
|
||||
"offset_m": -2.6,
|
||||
"elevation_m": 857.0106
|
||||
},
|
||||
{
|
||||
"offset_m": -2.5,
|
||||
"elevation_m": 857.0106
|
||||
},
|
||||
{
|
||||
"offset_m": -2.3,
|
||||
"elevation_m": 857.0106
|
||||
},
|
||||
{
|
||||
"offset_m": -2.0,
|
||||
"elevation_m": 857.3106
|
||||
},
|
||||
{
|
||||
"offset_m": -1.5,
|
||||
"elevation_m": 857.3256
|
||||
},
|
||||
{
|
||||
"offset_m": -1.0,
|
||||
"elevation_m": 857.3406
|
||||
},
|
||||
{
|
||||
"offset_m": -0.5,
|
||||
"elevation_m": 857.3556
|
||||
},
|
||||
{
|
||||
"offset_m": 0.0,
|
||||
"elevation_m": 857.3706
|
||||
},
|
||||
{
|
||||
"offset_m": 0.5,
|
||||
"elevation_m": 857.3856
|
||||
},
|
||||
{
|
||||
"offset_m": 1.0,
|
||||
"elevation_m": 857.4006
|
||||
},
|
||||
{
|
||||
"offset_m": 1.5,
|
||||
"elevation_m": 857.4156
|
||||
},
|
||||
{
|
||||
"offset_m": 2.0,
|
||||
"elevation_m": 857.4306
|
||||
},
|
||||
{
|
||||
"offset_m": 2.5,
|
||||
"elevation_m": 857.4456
|
||||
},
|
||||
{
|
||||
"offset_m": 3.0,
|
||||
"elevation_m": 857.4606
|
||||
},
|
||||
{
|
||||
"offset_m": 3.5,
|
||||
"elevation_m": 857.4756
|
||||
},
|
||||
{
|
||||
"offset_m": 4.0,
|
||||
"elevation_m": 857.4906
|
||||
},
|
||||
{
|
||||
"offset_m": 4.2,
|
||||
"elevation_m": 857.6677
|
||||
},
|
||||
{
|
||||
"offset_m": 4.5,
|
||||
"elevation_m": 857.4423
|
||||
},
|
||||
{
|
||||
"offset_m": 5.0,
|
||||
"elevation_m": 857.0211
|
||||
},
|
||||
{
|
||||
"offset_m": 5.5,
|
||||
"elevation_m": 856.6645
|
||||
},
|
||||
{
|
||||
"offset_m": 6.0,
|
||||
"elevation_m": 856.3885
|
||||
},
|
||||
{
|
||||
"offset_m": 6.5,
|
||||
"elevation_m": 856.1646
|
||||
},
|
||||
{
|
||||
"offset_m": 7.0,
|
||||
"elevation_m": 855.8407
|
||||
},
|
||||
{
|
||||
"offset_m": 7.5,
|
||||
"elevation_m": 855.4155
|
||||
},
|
||||
{
|
||||
"offset_m": 8.0,
|
||||
"elevation_m": 855.152
|
||||
},
|
||||
{
|
||||
"offset_m": 8.5,
|
||||
"elevation_m": 854.8834
|
||||
},
|
||||
{
|
||||
"offset_m": 9.0,
|
||||
"elevation_m": 854.5297
|
||||
},
|
||||
{
|
||||
"offset_m": 9.5,
|
||||
"elevation_m": 854.2107
|
||||
},
|
||||
{
|
||||
"offset_m": 10.0,
|
||||
"elevation_m": 853.9361
|
||||
},
|
||||
{
|
||||
"offset_m": 10.5,
|
||||
"elevation_m": 853.6676
|
||||
},
|
||||
{
|
||||
"offset_m": 11.0,
|
||||
"elevation_m": 853.3153
|
||||
},
|
||||
{
|
||||
"offset_m": 11.5,
|
||||
"elevation_m": 852.9625
|
||||
},
|
||||
{
|
||||
"offset_m": 12.0,
|
||||
"elevation_m": 852.5926
|
||||
},
|
||||
{
|
||||
"offset_m": 12.5,
|
||||
"elevation_m": 852.3655
|
||||
},
|
||||
{
|
||||
"offset_m": 13.0,
|
||||
"elevation_m": 852.2418
|
||||
},
|
||||
{
|
||||
"offset_m": 13.5,
|
||||
"elevation_m": 852.1686
|
||||
},
|
||||
{
|
||||
"offset_m": 14.0,
|
||||
"elevation_m": 852.1379
|
||||
},
|
||||
{
|
||||
"offset_m": 14.5,
|
||||
"elevation_m": 852.1222
|
||||
},
|
||||
{
|
||||
"offset_m": 15.0,
|
||||
"elevation_m": 852.0827
|
||||
},
|
||||
{
|
||||
"offset_m": 15.5,
|
||||
"elevation_m": 851.5817
|
||||
},
|
||||
{
|
||||
"offset_m": 16.0,
|
||||
"elevation_m": 850.7023
|
||||
},
|
||||
{
|
||||
"offset_m": 16.5,
|
||||
"elevation_m": 849.7513
|
||||
},
|
||||
{
|
||||
"offset_m": 17.0,
|
||||
"elevation_m": 849.2712
|
||||
},
|
||||
{
|
||||
"offset_m": 17.5,
|
||||
"elevation_m": 849.0234
|
||||
},
|
||||
{
|
||||
"offset_m": 18.0,
|
||||
"elevation_m": 848.776
|
||||
},
|
||||
{
|
||||
"offset_m": 18.5,
|
||||
"elevation_m": 848.5844
|
||||
},
|
||||
{
|
||||
"offset_m": 19.0,
|
||||
"elevation_m": 848.5525
|
||||
},
|
||||
{
|
||||
"offset_m": 19.5,
|
||||
"elevation_m": 848.5248
|
||||
},
|
||||
{
|
||||
"offset_m": 20.0,
|
||||
"elevation_m": 848.3385
|
||||
}
|
||||
],
|
||||
"cut_slope_segments": [
|
||||
{
|
||||
"side": "left",
|
||||
"ratio": 1.0,
|
||||
"rise_m": 0.2,
|
||||
"run_m": 0.2,
|
||||
"start_offset_m": 4.0,
|
||||
"end_offset_m": 4.2,
|
||||
"material": "soil"
|
||||
},
|
||||
{
|
||||
"side": "right",
|
||||
"ratio": 0.4,
|
||||
"rise_m": 8.3647,
|
||||
"run_m": 3.3459,
|
||||
"start_offset_m": -2.9,
|
||||
"end_offset_m": -6.2459,
|
||||
"material": "rock"
|
||||
},
|
||||
{
|
||||
"side": "right",
|
||||
"ratio": 1.0,
|
||||
"rise_m": 1.5041,
|
||||
"run_m": 1.5041,
|
||||
"start_offset_m": -6.2459,
|
||||
"end_offset_m": -7.75,
|
||||
"material": "soil"
|
||||
}
|
||||
],
|
||||
"rock_boundary_offset_m": -0.5,
|
||||
"status": "provisional",
|
||||
"pavement_suggested": false,
|
||||
"revet_adjust": {
|
||||
"outlet": {
|
||||
"x": 0.0,
|
||||
"d": 9.0,
|
||||
"h": null,
|
||||
"m": null
|
||||
}
|
||||
},
|
||||
"pipe_length_m": 18.0,
|
||||
"pipe_slope_pct": 42.0261,
|
||||
"extra_walls": [],
|
||||
"wall_fill_overlap": {
|
||||
"outlet": 0
|
||||
},
|
||||
"inlet_basin": "L"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* 186-1 묻힌 관 유출 벽 설계선 접힘 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* 입력 = 검증_랩탑메인 No.14+12.22 사본(`fixtures/b06_186_1_pipe_292.json`) · 73-4 픽스처 관 340(절토 쪽 유출).
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_186_1_outlet_fold.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
// 화면 전용 모듈(토스트) — 기하 계산엔 안 쓰임
|
||||
const STUBS = {
|
||||
"@ui/ui_template_toast": { showToast: () => undefined },
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", f);
|
||||
const { computeStoredLayouts, structureAreaRows } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
|
||||
const { computeCulvertLayout } = loadTs(B06("B06_Section_UI_Cross_Culvert_Geom.ts"));
|
||||
const read = (name) =>
|
||||
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections;
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
const r2 = (v) => Math.round(v * 100) / 100;
|
||||
|
||||
/** 유출 쪽 설계선(트림 사면) 점열 · 유출 벽 · 면적 행. */
|
||||
function study(sections, target) {
|
||||
const section = sections.find((s) => Math.abs(s.chainage_m - target) < 0.01);
|
||||
const { culvert } = computeStoredLayouts(section, sections);
|
||||
const wall = culvert.walls.find((w) => w.role === "outlet");
|
||||
const trim = culvert.designTrim;
|
||||
const slope = (wall.outward > 0 ? trim.maxSlope : trim.minSlope).points;
|
||||
const row = structureAreaRows(sections).find((r) => Math.abs(r.chainage_m - target) < 0.01);
|
||||
return {
|
||||
outward: wall.outward,
|
||||
wallTop: r2(wall.topJoint.elevation),
|
||||
groundAtWall: r2(groundOf(section, wall.topJoint.offset)),
|
||||
offsets: slope.map((p) => r2(p.offset)),
|
||||
elevations: slope.map((p) => r2(p.elevation)),
|
||||
cut: row ? row.cut_area_m2 : null,
|
||||
fill: row ? row.fill_area_m2 : null,
|
||||
};
|
||||
}
|
||||
function groundOf(section, offset) {
|
||||
const s = section.samples;
|
||||
for (let i = 1; i < s.length; i += 1)
|
||||
if (s[i].offset_m >= offset) {
|
||||
const t = (offset - s[i - 1].offset_m) / (s[i].offset_m - s[i - 1].offset_m);
|
||||
return s[i - 1].elevation_m + (s[i].elevation_m - s[i - 1].elevation_m) * t;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
// ① 저장값 그대로(유출 d 9.0) · ② 자동 자리(조작값 없음)
|
||||
const stored = read("b06_186_1_pipe_292.json");
|
||||
out.stored = study(stored, 292.22);
|
||||
const auto = clone(stored);
|
||||
auto[0].design.revet_adjust = null;
|
||||
out.auto = study(auto, 292.22);
|
||||
// ③ 91-4 절토 쪽 배관 유출(73-4 픽스처 관 340 · 유출 = 우측 절토)
|
||||
const base = read("b06_73_4_body_guard_sections.json");
|
||||
const s340 = clone(base.find((s) => Math.abs(s.chainage_m - 340) < 0.6));
|
||||
s340.uphill_side = "left";
|
||||
s340.culvert = {
|
||||
type: "pipe",
|
||||
chainage_m: 340,
|
||||
pipe_kind: "흄관",
|
||||
diameter_m: 1.0,
|
||||
min_cover_m: 0.5,
|
||||
inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
|
||||
outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" },
|
||||
};
|
||||
s340.design.inlet_structure = "revet";
|
||||
const layout = computeCulvertLayout(s340, s340.samples, undefined, "revet");
|
||||
const wall340 = layout.walls.find((w) => w.role === "outlet");
|
||||
const slope340 = (wall340.outward > 0 ? layout.designTrim.maxSlope : layout.designTrim.minSlope)
|
||||
.points;
|
||||
out.cut340 = {
|
||||
outward: wall340.outward,
|
||||
offsets: slope340.map((p) => r2(p.offset)),
|
||||
elevations: slope340.map((p) => r2(p.elevation)),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,236 @@
|
||||
/* PLAN 188-1 단위검증 헬퍼 — 종단(B05) ↔ 횡단(B06) 오갈 때 마지막 고름 하나만 이김.
|
||||
* 화면 TS(`B05_Profile_UI_Selection` · `design_selection` · 넘김 · 카드 찾기)를 그 자리에서
|
||||
* 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. 짝 = `test_188_1_last_pick.py`.
|
||||
* 포트 배선은 `design_view_profile.ts` 와 같은 꼴(remember = 고름 한 칸 · 3D 띠 리스너 = 선택 동기화).
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
const session = new Map();
|
||||
const fakeStorage = {
|
||||
getItem: (k) => session.get(k) ?? null,
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
};
|
||||
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
|
||||
globalThis.sessionStorage = fakeStorage;
|
||||
globalThis.localStorage = fakeStorage;
|
||||
globalThis.requestAnimationFrame = (fn) => fn();
|
||||
|
||||
// 비정규 측점 모듈은 설정 별칭(@config)을 끌어오므로 쓰는 두 함수만 같은 꼴로 흉내
|
||||
const STUBS = {
|
||||
"./B05_Profile_UI_IrregularStations": {
|
||||
irregularStationId: (id) => `irregular:${id}`,
|
||||
isPipeStation: (s) => s.origin === "pipe",
|
||||
},
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
STUBS[request] ??
|
||||
(request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
|
||||
const sync = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Selection.ts"));
|
||||
const pickSession = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"),
|
||||
);
|
||||
const pickPage = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts"));
|
||||
|
||||
const P = "proj-188";
|
||||
// 카드(횡단) = 일반 측점 + 배관 투영 측점(irregular:p1 · 33.4) · 옹벽 60~80 · 물넘이포장(측점선 없는 관 50)
|
||||
const cards = [
|
||||
["s0", 0],
|
||||
["s20", 20],
|
||||
["irregular:p1", 33.4],
|
||||
["s40", 40],
|
||||
["s60", 60],
|
||||
["s80", 80],
|
||||
["s100", 100],
|
||||
].map(([station_id, chainage_m]) => ({ station_id, chainage_m, ford: null, box: null }));
|
||||
const detail = { cross_sections: cards };
|
||||
const irregular = [{ id: "p1", chainage_m: 33.4, origin: "pipe", structure: "배관" }];
|
||||
const pipes = [{ chainage_m: 33.4 }, { chainage_m: 50 }];
|
||||
const structures = [
|
||||
{ structure_id: "wall", placement: "interval", start_m: 60, end_m: 80, chainage_m: 60 },
|
||||
];
|
||||
|
||||
// ── B05 종단 보기 흉내 — design_view_profile 의 포트 배선 그대로 ─────────────────
|
||||
let syncing = false;
|
||||
const graph = { station: null, mark: null };
|
||||
let marker = null;
|
||||
const sb = sync.createSelectionSync({
|
||||
stations: () => irregular,
|
||||
isSyncing: () => syncing,
|
||||
setSyncing: (v) => {
|
||||
syncing = v;
|
||||
},
|
||||
// 3D 띠 고름은 리스너로 선택 동기화를 되부름(onStationSelectionChange)
|
||||
selectMarker: (id) => {
|
||||
marker = id;
|
||||
sb.syncIrregularSelection(id);
|
||||
},
|
||||
selectGraph: (id, c) => {
|
||||
const target = sync.graphTargetAt(id, c ?? null, pipes, cards);
|
||||
if ("pipeChainageM" in target) graph.mark = `pipe-${target.pipeChainageM.toFixed(2)}`;
|
||||
else {
|
||||
graph.station = target.stationId;
|
||||
// 관도 없는 자리 = 그 측점선 · 3D 띠(view 의 selectGraphAt 과 같음)
|
||||
if (id === null && target.stationId !== null) {
|
||||
marker = target.stationId;
|
||||
sb.syncIrregularSelection(target.stationId);
|
||||
}
|
||||
}
|
||||
},
|
||||
selectSidebar: () => undefined,
|
||||
selectBasin: () => undefined,
|
||||
regularChainage: (id) => cards.find((c) => c.station_id === id)?.chainage_m,
|
||||
remember: (c) =>
|
||||
sel.selectDesign(P, c === null ? sel.NO_SELECTION : { chainageM: c, structureId: null }, {
|
||||
from: "b05",
|
||||
}),
|
||||
});
|
||||
|
||||
/** B05 사용자 고름 — 측점선 · 3D 띠 · 표(선택 동기화) · 알약 · 목록(직접 적음). */
|
||||
const b05 = {
|
||||
line: (id) => sb.syncIrregularSelection(id),
|
||||
pill: (c) => sel.selectDesign(P, { chainageM: c, structureId: null }, { from: "b05" }),
|
||||
structure: (sid, c) => sel.selectDesign(P, { chainageM: c, structureId: sid }, { from: "b05" }),
|
||||
};
|
||||
|
||||
/** 횡단 탭으로 — 넘김값으로 연 카드 id(없으면 null). */
|
||||
function toB06() {
|
||||
let focused = null;
|
||||
const handoff = pickSession.readStructurePick(P);
|
||||
pickPage.applyStructurePick(
|
||||
handoff,
|
||||
detail,
|
||||
{
|
||||
focusStation: (id) => {
|
||||
focused = id;
|
||||
},
|
||||
refreshCard: () => undefined,
|
||||
revetSelect: () => undefined,
|
||||
fordSelect: () => undefined,
|
||||
boxSelect: () => undefined,
|
||||
},
|
||||
structures,
|
||||
);
|
||||
return focused;
|
||||
}
|
||||
|
||||
/** 종단 탭으로 — 넘김값을 되살린 뒤 그래프 · 3D · 고름 한 칸. */
|
||||
function toB05() {
|
||||
graph.station = null;
|
||||
graph.mark = null;
|
||||
marker = null;
|
||||
const opened = [];
|
||||
pickSession.restoreStructurePick(
|
||||
{ select: () => undefined },
|
||||
P,
|
||||
{
|
||||
hasSelection: () => opened.length > 0,
|
||||
selectAt: (s) => opened.push(s.structureId),
|
||||
},
|
||||
sb.selectStationOfPipe,
|
||||
);
|
||||
return { graph: { ...graph }, marker, opened, pick: sel.readSelection(P) };
|
||||
}
|
||||
|
||||
const reset = () => sel.selectDesign(P, sel.NO_SELECTION);
|
||||
const out = {};
|
||||
|
||||
// ① 배관 → 일반 측점 → 횡단 = 일반 측점(사용자 재현)
|
||||
reset();
|
||||
b05.line("irregular:p1");
|
||||
b05.line("s20");
|
||||
out.pipeThenStation = toB06();
|
||||
reset();
|
||||
b05.pill(33.4); // 알약으로 고른 배관도 같음
|
||||
b05.line("s20");
|
||||
out.pillThenStation = toB06();
|
||||
|
||||
// ② 일반 측점 → 배관 → 횡단 = 배관
|
||||
reset();
|
||||
b05.line("s20");
|
||||
b05.line("irregular:p1");
|
||||
out.stationThenPipe = toB06();
|
||||
|
||||
// ③ 구조물 끼움 — 배관 → 구조물 → 일반 측점 = 일반 측점 · 일반 측점 → 구조물 = 구조물 시작 카드
|
||||
reset();
|
||||
b05.pill(33.4);
|
||||
b05.structure("wall", 60);
|
||||
b05.line("s100");
|
||||
out.structureThenStation = toB06();
|
||||
reset();
|
||||
b05.line("s100");
|
||||
b05.structure("wall", 60);
|
||||
out.stationThenStructure = toB06();
|
||||
reset();
|
||||
b05.structure("wall", 60);
|
||||
b05.line("irregular:p1");
|
||||
out.structureThenPipe = toB06();
|
||||
|
||||
// ④ 고름 풂 · 모르는 측점 id
|
||||
reset();
|
||||
b05.line("irregular:p1");
|
||||
b05.line(null);
|
||||
out.released = pickSession.readStructurePick(P);
|
||||
reset();
|
||||
b05.line("s20");
|
||||
b05.line("ghost");
|
||||
out.unknownKeeps = pickSession.readStructurePick(P)?.at ?? null;
|
||||
|
||||
// ⑤ 횡단 → 종단 — B06 카드가 적은 값을 종단이 그대로 켬 · 되살리기는 고름 한 칸을 안 덮음
|
||||
reset();
|
||||
sel.selectDesign(P, { chainageM: 40, structureId: null }, { from: "b06" });
|
||||
out.b06Station = toB05();
|
||||
reset();
|
||||
sel.selectDesign(P, { chainageM: 33.4, structureId: null }, { from: "b06" });
|
||||
out.b06Pipe = toB05();
|
||||
reset();
|
||||
sel.selectDesign(P, { chainageM: 50, structureId: null }, { from: "b06" });
|
||||
out.b06BarePipe = toB05();
|
||||
reset();
|
||||
sel.selectDesign(P, { chainageM: 60, structureId: "wall" }, { partKey: "own", from: "b06" });
|
||||
out.b06Structure = toB05();
|
||||
|
||||
// ⑥ 오가기 — 종단 일반 측점 → 횡단 → 횡단 카드 → 종단 → 종단 배관 → 횡단
|
||||
reset();
|
||||
b05.line("s20");
|
||||
const trip = [toB06()];
|
||||
sel.selectDesign(P, { chainageM: 80, structureId: null }, { from: "b06" });
|
||||
trip.push(toB05().graph.station);
|
||||
b05.line("irregular:p1");
|
||||
trip.push(toB06());
|
||||
b05.line("s0");
|
||||
trip.push(toB06());
|
||||
out.roundTrip = trip;
|
||||
|
||||
// ⑦ 그래프 표적 — 측점 id 우선 · 관 알약 · 그 자리 측점선 · 아무것도 없음
|
||||
out.graphTarget = {
|
||||
byId: sync.graphTargetAt("s20", 33.4, pipes, cards),
|
||||
bare: sync.graphTargetAt(null, 50.2, pipes, cards),
|
||||
station: sync.graphTargetAt(null, 100.3, pipes, cards),
|
||||
far: sync.graphTargetAt(null, 120, pipes, cards),
|
||||
none: sync.graphTargetAt(null, null, pipes, cards),
|
||||
};
|
||||
reset();
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -185,9 +185,10 @@ function cutOutlet(edit = () => undefined) {
|
||||
s.design.inlet_structure = "revet";
|
||||
edit(s);
|
||||
const layout = computeCulvertLayout(s, s.samples, undefined, "revet");
|
||||
const wall = layout.walls.find((w) => w.role === "outlet");
|
||||
// 벽 뒤 절토 = 유출 끝 연결선(139-4) — 벽 모서리 절토는 또 잇지 않음(186-1)
|
||||
const start = layout.outletFill.segments.at(-1).points[0];
|
||||
const pts = layout.designTrim.minSlope.points;
|
||||
const from = pts.findIndex((p) => Math.abs(p.offset - wall.points[2].offset) < 1e-9);
|
||||
const from = pts.findIndex((p) => Math.abs(p.offset - start.offset) < 1e-9);
|
||||
return profile(pts.slice(from), s.samples);
|
||||
}
|
||||
out.cutOutlet = cutOutlet();
|
||||
|
||||
@@ -195,37 +195,50 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
|
||||
|
||||
|
||||
def test_우클릭_메뉴_줄_추가_지우기_연결_TS():
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit"])
|
||||
# 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"])
|
||||
rows = [
|
||||
{"kind": "title", "key": "sec:1.2", "section": "1.2"},
|
||||
{"kind": "item", "key": "earth:4", "section": "1.2"},
|
||||
{"kind": "item", "key": "hand:b", "section": "1.2", "hand": True},
|
||||
]
|
||||
{"kind": "title", "key": "sec:1", "parent": None, "auto": True},
|
||||
{"kind": "title", "key": "sec:1.2", "parent": "sec:1", "auto": True},
|
||||
{"kind": "item", "key": "earth:4", "parent": "sec:1.2", "auto": True, "qty": 100,
|
||||
"auto_qty": 5675},
|
||||
{"kind": "item", "key": "hand:b", "parent": "sec:1.2", "hand": True},
|
||||
] # fmt: skip
|
||||
body = f"""
|
||||
const rows = {json.dumps(rows, ensure_ascii=False)};
|
||||
const first = {json.dumps(HAND[1], ensure_ascii=False)};
|
||||
let user = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
|
||||
const start = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }};
|
||||
let user = start;
|
||||
const ctx = {{ sheets: {{}}, payload: {{}}, get user() {{ return user; }},
|
||||
setUser: (u) => (user = u) }};
|
||||
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
|
||||
const labels = (i) => billMenu(ctx, rows, i).map(name);
|
||||
const out = {{ title: labels(0), auto: labels(1), hand: labels(2) }};
|
||||
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "위에 줄 추가")[1]();
|
||||
out.above = user.bill.rows[1];
|
||||
billMenu(ctx, rows, 1).find((x) => Array.isArray(x) && x[0] === "수량 자동으로 되돌리기")[1]();
|
||||
out.qty = user.bill.qty;
|
||||
billMenu(ctx, rows, 2).find((x) => Array.isArray(x) && x[0] === "줄 지우기")[1]();
|
||||
out.left = user.bill.rows.map((r) => [r.key.slice(0, 5), r.after]);
|
||||
out.edit = ["name", "qty", "sum"].map(handEditable);
|
||||
const run = (i, label) => {{
|
||||
user = start;
|
||||
billMenu(ctx, rows, i).find((x) => Array.isArray(x) && x[0] === label)[1]();
|
||||
return user.bill;
|
||||
}};
|
||||
const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }};
|
||||
out.above = run(2, "위에 줄 추가");
|
||||
out.reset = run(2, "자동값으로 되돌리기");
|
||||
out.left = run(3, "줄 지우기");
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
out = json.loads(_node(src + body))
|
||||
assert out["title"] == ["아래에 줄 추가"]
|
||||
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
|
||||
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨
|
||||
assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄"
|
||||
assert out["qty"] == {}
|
||||
# hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
|
||||
assert out["left"] == [["hand:", "sec:1.2"]]
|
||||
assert out["edit"] == [True, True, False]
|
||||
add = ["위에 줄 추가", "아래에 줄 추가"]
|
||||
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
|
||||
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
|
||||
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
|
||||
"내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
|
||||
above = out["above"]
|
||||
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
|
||||
new, earth, hand = above["rows"][2:]
|
||||
assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2)
|
||||
assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100}
|
||||
assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"])
|
||||
assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움
|
||||
# 새 줄 지움 = 덮개에서 빠짐(gone 은 자동 줄만)
|
||||
assert [e["key"] for e in out["left"]["rows"]] == ["sec:1", "sec:1.2", "earth:4"]
|
||||
assert out["left"]["gone"] == []
|
||||
|
||||
@@ -146,13 +146,14 @@ def test_화면_보기_바꿈_누름_이동():
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l),"
|
||||
" redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table'),"
|
||||
" focusRow: (i) => focused.push(i) }) };"
|
||||
"\nconst [bar, view] = renderGroupPanel(o);"
|
||||
"\nconst label = bar.children[0].textContent;"
|
||||
"\nconst [view] = renderGroupPanel(o);"
|
||||
"\nconst btn = view.querySelector('[data-b09-group-view]');"
|
||||
"\nconst label = btn.textContent;"
|
||||
"\nview.querySelector('[data-at=\"2\"]').click();"
|
||||
"\nview.querySelector('[data-at=\"0\"]').click();"
|
||||
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
|
||||
"\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');"
|
||||
"\nbar.children[0].click();"
|
||||
"\nbtn.click();"
|
||||
"\nconst [bar2, view2] = renderGroupPanel(o);"
|
||||
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws,"
|
||||
" label2: bar2.children[0].textContent, tag2: view2.tagName, focused,"
|
||||
|
||||
@@ -177,46 +177,62 @@ const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
|
||||
const run = (key, label) => {
|
||||
user = start;
|
||||
billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label)[1]();
|
||||
return user.bill.rows;
|
||||
return user.bill;
|
||||
};
|
||||
// 1.2 공종 차례 [키(새 줄 = NEW), 부모]
|
||||
const order = (bill) => {
|
||||
const list = bill.rows.map((r) => [r.name === "새 줄" ? "NEW" : r.key, r.parent ?? null]);
|
||||
const from = list.findIndex(([k]) => k === "sec:1.2");
|
||||
return list.slice(from + 1, list.findIndex(([k]) => k === "earth:5") + 1);
|
||||
};
|
||||
const out = {
|
||||
g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"),
|
||||
b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"),
|
||||
};
|
||||
out.child = run("hand:g2", "아래에 하위 줄 추가").at(-1);
|
||||
out.below = run("hand:g1", "아래에 줄 추가").at(-1);
|
||||
out.belowB = run("hand:b", "아래에 줄 추가").at(-1);
|
||||
out.indent = run("hand:x", "들여쓰기 →").find((r) => r.key === "hand:x");
|
||||
out.outdent = run("hand:g2", "내어쓰기 ←").find((r) => r.key === "hand:g2");
|
||||
out.toItem = run("hand:g2", "항목으로 바꾸기").filter((r) => ["hand:g2", "hand:b"].includes(r.key));
|
||||
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").find((r) => r.key === "hand:x");
|
||||
out.removed = run("hand:g1", "줄 지우기").filter((r) => ["hand:a", "hand:g2"].includes(r.key));
|
||||
out.child = order(run("hand:g2", "아래에 하위 줄 추가"));
|
||||
out.below = order(run("hand:g1", "아래에 줄 추가"));
|
||||
out.belowB = order(run("hand:b", "아래에 줄 추가"));
|
||||
out.indentBill = run("hand:x", "들여쓰기 →");
|
||||
out.indent = order(out.indentBill);
|
||||
out.outdent = order(run("hand:g2", "내어쓰기 ←"));
|
||||
out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key));
|
||||
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x");
|
||||
out.removed = order(run("hand:g1", "줄 지우기"));
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
#: 1.2 공종 기본 차례(셈 출력) — [키, 부모]
|
||||
BASE = [
|
||||
["earth:4", "sec:1.2"], ["hand:g1", "sec:1.2"], ["hand:a", "hand:g1"], ["hand:g2", "hand:g1"],
|
||||
["hand:b", "hand:g2"], ["hand:c", "hand:g2"], ["hand:x", "sec:1.2"], ["earth:5", "sec:1.2"],
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def _put(order, at, *pairs):
|
||||
return [*order[:at], *[list(p) for p in pairs], *order[at:]]
|
||||
|
||||
|
||||
def test_우클릭_레벨_메뉴_TS():
|
||||
# 184-3 — 모든 줄 메뉴 · 손질 = user.bill v2(차례 · 부모)
|
||||
rows = compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
|
||||
body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace(
|
||||
"__USER__", json.dumps(USER, ensure_ascii=False)
|
||||
)
|
||||
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit"]) + body))
|
||||
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body))
|
||||
level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"}
|
||||
pick = lambda key: [x for x in out[key] if x in level] # noqa: E731
|
||||
assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"]
|
||||
assert "연결" not in out["g1"] and pick("g1") == ["항목으로 바꾸기"]
|
||||
assert pick("a") == ["묶음 줄로 바꾸기"] # 뒤에 형제(g2) → 내어쓰기 없음
|
||||
assert pick("g2") == ["항목으로 바꾸기", "내어쓰기 ←"] # 막내
|
||||
assert pick("b") == [] # 묶음 안 묶음 안 = 깊이 한도 · 형제 c 뒤 → 내어쓰기 없음
|
||||
assert pick("x") == ["묶음 줄로 바꾸기", "들여쓰기 →"] # 위 형제 g1
|
||||
assert pick("auto") == [] and "줄 지우기" not in out["auto"]
|
||||
assert {k: out["child"][k] for k in ("section", "after", "parent")} == {
|
||||
"section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip
|
||||
# g1 자손 끝 · 부모 = 셈 parent 그대로(184 공종 제목 키 — 옛 꼴 다듬기가 hand: 아닌 부모 버림)
|
||||
assert (out["below"]["after"], out["below"]["parent"]) == ("hand:c", "sec:1.2")
|
||||
assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2")
|
||||
assert out["indent"]["parent"] == "hand:g1"
|
||||
assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래
|
||||
assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"]
|
||||
assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음
|
||||
assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목
|
||||
assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지
|
||||
assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1
|
||||
assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움
|
||||
assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤
|
||||
assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모
|
||||
assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"])
|
||||
assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식
|
||||
assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤
|
||||
assert [(r["key"], r.get("kind"), r["parent"]) for r in out["toItem"]] == [
|
||||
("hand:g2", None, "hand:g1"), ("hand:b", None, "hand:g1")] # fmt: skip
|
||||
assert (out["toGroup"]["kind"], out["toGroup"]["qty"], out["toGroup"]["ref"]) == (
|
||||
@@ -224,12 +240,10 @@ def test_우클릭_레벨_메뉴_TS():
|
||||
None,
|
||||
None,
|
||||
)
|
||||
# g1 지움 — 자식은 공종 아래로 · 바로 뒤 줄은 g1 자리(earth:4 뒤)로
|
||||
assert [(r["key"], r["after"], r.get("parent")) for r in out["removed"]] == [
|
||||
("hand:a", "earth:4", None), ("hand:g2", "hand:a", None)] # fmt: skip
|
||||
# g1 지움 — 자식은 g1 부모(공종) 아래 · 그 자리
|
||||
assert out["removed"] == [BASE[0], ["hand:a", "sec:1.2"], ["hand:g2", "sec:1.2"], *BASE[4:]]
|
||||
# 들여쓴 x = g1 금액에 듦(셈 짝 그대로)
|
||||
user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}}
|
||||
rows2 = _rows(compute_estimation(INPUTS, user, None, BOOKS))
|
||||
rows2 = _rows(compute_estimation(INPUTS, {"bill": out["indentBill"]}, None, BOOKS))
|
||||
assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230)
|
||||
|
||||
|
||||
@@ -274,7 +288,7 @@ table.onCell(at("hand:g1"), "connect");
|
||||
table.onCell(at("earth:4"), "connect");
|
||||
table.onCell(at("hand:c"), "connect");
|
||||
out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]);
|
||||
opened[1].items[0][1](); // hand:c → 일위대가 호표 고르기 창
|
||||
opened[2].items[0][1](); // hand:c → 일위대가 호표 고르기 창
|
||||
picked.rows[0].onPick();
|
||||
out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)];
|
||||
out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref;
|
||||
@@ -289,17 +303,18 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
|
||||
.replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False))
|
||||
.replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False))
|
||||
)
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
|
||||
out = json.loads(_node(src + body))
|
||||
assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸
|
||||
assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호
|
||||
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
|
||||
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
|
||||
assert out["cls"] == [
|
||||
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
|
||||
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
|
||||
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
|
||||
# 누른 칸 아래(left · bottom) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함
|
||||
assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]]
|
||||
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
|
||||
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
|
||||
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
|
||||
assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용
|
||||
assert out["ref"] == DG
|
||||
|
||||
@@ -327,6 +342,7 @@ console.log(JSON.stringify(out));
|
||||
|
||||
|
||||
def test_글_칸_위_우클릭도_줄_메뉴_TS():
|
||||
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + ROW_MENU_JS))
|
||||
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"])
|
||||
out = json.loads(_node(src + ROW_MENU_JS))
|
||||
assert out["onInput"] is True and out["onCell"] is True
|
||||
assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래.
|
||||
"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 견적서 = 견적 아래 · 다른 출처 「출처」 열은 185-4 에서 호버로.
|
||||
|
||||
소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4).
|
||||
"""
|
||||
@@ -14,11 +14,12 @@ def _compare() -> str:
|
||||
return SRC[start : SRC.index("export const buildMaterial")]
|
||||
|
||||
|
||||
def test_견적은_단가_견적서_그_밖은_단가_출처():
|
||||
def test_견적은_단가_견적서_그_밖은_단가_하나():
|
||||
# 185-4 — 출처 열 없앰(출처 뜻 = 단가 칸 호버 · test_185_4)
|
||||
body = _compare()
|
||||
assert (
|
||||
's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body
|
||||
)
|
||||
assert '[money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]' in body
|
||||
assert ': [money(`p:${s}`, "단가", [s])],' in body
|
||||
assert "g:${s}" not in body and '"출처", 64' not in body
|
||||
assert '"면"' not in body # 면 머리 없음
|
||||
assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 ·
|
||||
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선.
|
||||
|
||||
- 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄
|
||||
- 저장 = `user.bill` v2 {v: 2, rows: 표 차례 {key · parent · 고친 칸}, gone}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
|
||||
DG = {"tab": "dg", "key": "DG:a"}
|
||||
|
||||
|
||||
def _row(key, parent, level, kind="item", cost="direct", **more):
|
||||
auto = not key.startswith("hand:")
|
||||
row = {"key": key, "kind": kind, "parent": parent, "level": level, "cost": cost, "no": "",
|
||||
"name": key, "spec": "", "unit": "", "qty": None, "ref": None, "note": ""} # fmt: skip
|
||||
return {**row, **({"auto": True} if auto else {"hand": True}), **more}
|
||||
|
||||
|
||||
ROWS = [
|
||||
_row("sec:1", None, 1, "title", no="1", name="토공사"),
|
||||
_row("sec:1.2", "sec:1", 2, "title", no="1.1", name="절토"),
|
||||
_row("earth:4", "sec:1.2", 3, qty=10, ref={"tab": "sg_list", "key": "D01341"}),
|
||||
_row("earth:5", "sec:1.2", 3, qty=9, auto_qty=5),
|
||||
_row("hand:g", "sec:1.2", 3, "group"),
|
||||
_row("hand:a", "hand:g", 4, ref=DG, note="대가3"),
|
||||
_row("hand:b", "sec:1.2", 3),
|
||||
_row("sec:4", None, 1, "title", no="4", name="자재대"),
|
||||
_row("mat:M1", "sec:4", 2, qty=3),
|
||||
_row("sec:G", None, 1, "title", "supply", name="관급자재대"),
|
||||
_row("mat:M2", "sec:G", 2, cost="supply", qty=4),
|
||||
_row("fee", "sec:G", 2, cost="supply", qty=None),
|
||||
]
|
||||
#: 옛 꼴 덮개(166-2 · 182) — 자동 줄 수량 · 손 줄
|
||||
OLD = {
|
||||
"bill": {
|
||||
"qty": {"earth:5": 9},
|
||||
"rows": [
|
||||
{"key": "hand:g", "section": "1.2", "after": "earth:5", "kind": "group", "name": "g"},
|
||||
{"key": "hand:a", "section": "1.2", "after": "hand:g", "parent": "hand:g", "name": "a",
|
||||
"qty": 2, "ref": DG},
|
||||
{"key": "hand:b", "section": "1.2", "after": "hand:a", "name": "b", "qty": 1},
|
||||
],
|
||||
}
|
||||
} # fmt: skip
|
||||
|
||||
MENU_JS = """
|
||||
const rows = __ROWS__;
|
||||
const start = __USER__;
|
||||
let user = start;
|
||||
const ctx = { sheets: {}, payload: {}, get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
|
||||
const at = (key) => rows.findIndex((r) => r.key === key);
|
||||
const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
|
||||
const run = (key, label) => {
|
||||
user = start;
|
||||
const item = billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label);
|
||||
if (!item) return null;
|
||||
item[1]();
|
||||
return user.bill;
|
||||
};
|
||||
const edit = (key, field, value) => {
|
||||
user = start;
|
||||
editCell(ctx, rows, at(key), field, value);
|
||||
return user.bill;
|
||||
};
|
||||
const out = { labels: {}, runs: {}, edits: {} };
|
||||
for (const key of __LABELS__) out.labels[key] = labels(key);
|
||||
for (const [key, label] of __RUNS__) out.runs[`${key}|${label}`] = run(key, label);
|
||||
for (const [key, field, value] of __EDITS__) out.edits[`${key}|${field}`] = edit(key, field, value);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript"],
|
||||
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _ts(names: list[str]) -> str:
|
||||
return "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||||
for n in names
|
||||
)
|
||||
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]
|
||||
|
||||
|
||||
def _menu(user, labels=(), runs=(), edits=(), rows=ROWS):
|
||||
body = (
|
||||
MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__USER__", json.dumps(user, ensure_ascii=False))
|
||||
.replace("__LABELS__", json.dumps(list(labels), ensure_ascii=False))
|
||||
.replace("__RUNS__", json.dumps([list(r) for r in runs], ensure_ascii=False))
|
||||
.replace("__EDITS__", json.dumps([list(e) for e in edits], ensure_ascii=False))
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + body))
|
||||
|
||||
|
||||
def _order(bill):
|
||||
return [(e["key"], e.get("parent")) for e in bill["rows"]]
|
||||
|
||||
|
||||
ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"]
|
||||
|
||||
|
||||
def test_메뉴_모든_줄():
|
||||
out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2",
|
||||
"mat:M1", "sec:G"])["labels"] # fmt: skip
|
||||
add = ["위에 줄 추가", "아래에 줄 추가"]
|
||||
# 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움)
|
||||
assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"]
|
||||
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
|
||||
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
|
||||
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
|
||||
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
|
||||
"내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
|
||||
"아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
||||
assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정
|
||||
assert out["mat:M2"] == [*add, "새 공종 추가(1단)"]
|
||||
assert out["mat:M1"] == [*add, "새 공종 추가(1단)"]
|
||||
assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"]
|
||||
|
||||
|
||||
def test_칸_고침_덮개_옛_꼴_굳힘():
|
||||
out = _menu(OLD, edits=[("earth:4", "name", "절토A"), ("earth:5", "qty", 5),
|
||||
("earth:4", "qty", 10), ("earth:4", "spec", None),
|
||||
("hand:a", "qty", 7), ("sec:1.2", "name", "절토공")])["edits"] # fmt: skip
|
||||
bill = out["earth:4|name"]
|
||||
# 첫 손질 = 보이는 줄 키 · 부모 · 차례로 v2(조달수수료 빼고) · 옛 손 줄 칸 · 수량 덮개 옮김
|
||||
assert (bill["v"], bill["gone"], _order(bill)) == (2, [], ORDER)
|
||||
rows = {e["key"]: e for e in bill["rows"]}
|
||||
assert rows["earth:4"] == {"key": "earth:4", "parent": "sec:1.2", "name": "절토A"}
|
||||
assert rows["earth:5"] == {"key": "earth:5", "parent": "sec:1.2", "qty": 9}
|
||||
assert rows["hand:g"] == {"key": "hand:g", "parent": "sec:1.2", "kind": "group", "name": "g"}
|
||||
assert rows["hand:a"] == {"key": "hand:a", "parent": "hand:g", "name": "a", "qty": 2, "ref": DG}
|
||||
assert "section" not in rows["hand:b"] and "after" not in rows["hand:b"]
|
||||
# 자동 값과 같게 고침 = 덮개 지움 · 안 고친 값 그대로 = 덮개 안 생김 · null = 일부러 빈칸
|
||||
assert "qty" not in {e["key"]: e for e in out["earth:5|qty"]["rows"]}["earth:5"]
|
||||
assert "qty" not in {e["key"]: e for e in out["earth:4|qty"]["rows"]}["earth:4"]
|
||||
assert {e["key"]: e for e in out["earth:4|spec"]["rows"]}["earth:4"]["spec"] is None
|
||||
assert {e["key"]: e for e in out["hand:a|qty"]["rows"]}["hand:a"]["qty"] == 7
|
||||
assert {e["key"]: e for e in out["sec:1.2|name"]["rows"]}["sec:1.2"]["name"] == "절토공"
|
||||
|
||||
|
||||
V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in ORDER
|
||||
if k != "hand:b"], "gone": []}} # fmt: skip
|
||||
|
||||
|
||||
def test_지움_옮김_새_공종_자동값으로():
|
||||
user = {"bill": {**V2["bill"], "rows": [
|
||||
{**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip
|
||||
runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"),
|
||||
("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"),
|
||||
("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"),
|
||||
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"),
|
||||
("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
|
||||
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"),
|
||||
("fee", "새 공종 추가(1단)")] # fmt: skip
|
||||
out = _menu(user, runs=runs)["runs"]
|
||||
keys = lambda b: [k for k, _ in _order(b)] # noqa: E731
|
||||
# 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘
|
||||
base = keys(out["earth:5|위로 ↑"])
|
||||
assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1
|
||||
# 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로
|
||||
b = out["earth:4|줄 지우기"]
|
||||
assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b)
|
||||
b = out["hand:g|줄 지우기"]
|
||||
assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b)
|
||||
# 위로 · 아래로 = 같은 부모 형제와 자리 바꿈
|
||||
for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"):
|
||||
got = keys(out[k])
|
||||
assert got[got.index("earth:5") + 1] == "earth:4"
|
||||
# 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞
|
||||
b = out["earth:4|새 공종 추가(1단)"]
|
||||
new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0]
|
||||
assert (new["kind"], new["cost"], new["name"], "parent" in new) == (
|
||||
"group", "direct", "새 공종", False) # fmt: skip
|
||||
assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4"
|
||||
b = out["mat:M2|새 공종 추가(1단)"]
|
||||
assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:")
|
||||
# 자동값으로 = 고친 칸 지움(차례 · 부모 그대로)
|
||||
b = out["earth:5|자동값으로 되돌리기"]
|
||||
assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"}
|
||||
# 들여쓰기 · 내어쓰기
|
||||
assert ("hand:b", "hand:g") in _order(out["hand:b|들여쓰기 →"])
|
||||
got = _order(out["hand:a|내어쓰기 ←"])
|
||||
assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2")
|
||||
got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤)
|
||||
assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0
|
||||
# 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목)
|
||||
got = out["sec:1.2|아래에 하위 줄 추가"]["rows"]
|
||||
kids = [e for e in got if e.get("parent") == "sec:1.2"]
|
||||
assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1]
|
||||
got = out["earth:4|위에 줄 추가"]["rows"]
|
||||
at = [e["key"] for e in got].index("earth:4")
|
||||
assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2")
|
||||
a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"]
|
||||
assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None)
|
||||
got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]}
|
||||
assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2"
|
||||
# 자동 줄 연결 풀기 = ref 덮개 null
|
||||
assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None
|
||||
# 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞
|
||||
got = out["fee|위에 줄 추가"]["rows"]
|
||||
assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2")
|
||||
assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2
|
||||
|
||||
|
||||
def test_관급_옮김_막힘_깊이_5():
|
||||
# 차례 = 1 · 관급자재대 · 4 자재대(별도 자재 mat:M1) · 새 공종(자재 없음)
|
||||
order = [*[(k, p) for k, p in ORDER if not k.startswith(("sec:4", "mat:M1"))],
|
||||
("sec:4", None), ("mat:M1", "sec:4"), ("hand:n", None)] # fmt: skip
|
||||
by = {r["key"]: r for r in ROWS}
|
||||
rows = [by.get(k) or _row(k, None, 1, "group") for k, _ in order]
|
||||
user = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in order],
|
||||
"gone": []}} # fmt: skip
|
||||
out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"]
|
||||
assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음
|
||||
assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대)
|
||||
# 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음
|
||||
deep = [
|
||||
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
|
||||
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
|
||||
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
|
||||
] # fmt: skip
|
||||
out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"]
|
||||
assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"]
|
||||
assert "아래에 하위 줄 추가" in out["hand:g2"]
|
||||
|
||||
|
||||
TABLE_JS = """
|
||||
let user = __USER__;
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const table = buildBill(ctx);
|
||||
const at = (key) => table.rows.findIndex((r) => r.key === key);
|
||||
const out = {
|
||||
names: ["sec:1", "sec:1.2", "earth:4"].map((k) => table.rows[at(k)].name),
|
||||
connect: ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"].map((k) => table.rows[at(k)].connect),
|
||||
cls: [["earth:5", "qty"], ["earth:5", "name"], ["earth:4", "qty"]].map(([k, c]) => table.cellClass(at(k), c)),
|
||||
tip: table.tip(at("earth:5"), "qty"),
|
||||
edit: [["sec:1", "name"], ["sec:1", "qty"], ["earth:4", "spec"], ["fee", "qty"], ["fee", "name"]]
|
||||
.map(([k, c]) => table.editable(at(k), c)),
|
||||
hint: table.hint,
|
||||
};
|
||||
table.onEdit(at("sec:1.2"), "name", "1.1 절토공");
|
||||
out.renamed = user.bill.rows.find((e) => e.key === "sec:1.2").name;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_표_칸_강조_번호_연결_칸():
|
||||
body = TABLE_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)).replace(
|
||||
"__USER__", "{}"
|
||||
)
|
||||
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
|
||||
assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no
|
||||
assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""]
|
||||
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 out["renamed"] == "절토공" # 번호는 떼고 이름만
|
||||
|
||||
|
||||
def test_내역_초기화_배선():
|
||||
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
block = src[src.index('label: "내역 초기화"') :]
|
||||
block = block[: block.index("b09BillReset")]
|
||||
# 확인 창 → bill 뺀 사용자 값으로 바로 PUT(덮개 빔) · 좌측 단추 줄
|
||||
assert "showConfirmDialog(" in block and "if (!ok) return;" in block
|
||||
assert "const { bill: _bill, ...rest } = user;" in block
|
||||
assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block
|
||||
assert "actions.append(save, reset);" in src
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS.
|
||||
|
||||
레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음).
|
||||
총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3.
|
||||
들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
|
||||
|
||||
BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]);
|
||||
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
|
||||
"""
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
names = ("Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total")
|
||||
script = "".join(
|
||||
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text(encoding="utf-8")) for n in names
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript"],
|
||||
input=script + BODY, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_총괄_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
|
||||
assert all(rows[k] == (1, "is-lv1 is-title") for k in top)
|
||||
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
|
||||
"supply"]: # fmt: skip
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert {lv for lv, _ in rows.values()} == {1, 2}
|
||||
|
||||
|
||||
def test_원가계산서_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
|
||||
assert rows[k] == (1, "is-lv1 is-title"), k
|
||||
for k in ["material", "labor", "expense_sum"]:
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
|
||||
assert rows[k] == (3, "is-lv3"), k
|
||||
|
||||
|
||||
def test_칸_class_명칭만_들여씀():
|
||||
out = _run()
|
||||
for tab in ("total", "cost"):
|
||||
for _k, _lv, _cls, name, first, amount in out[tab]:
|
||||
assert (name, first, amount) == ("is-indent", "is-flat", None)
|
||||
|
||||
|
||||
def test_CSS_레벨_규칙():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8")
|
||||
assert re.search(
|
||||
r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css
|
||||
)
|
||||
for lv, em in ((2, 1), (3, 2)):
|
||||
assert re.search(
|
||||
rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);",
|
||||
css,
|
||||
)
|
||||
# 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지)
|
||||
assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""185-2 B09 묶음 탭 도구 줄 — 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
|
||||
|
||||
- 카드 보기 = 보기 바꿈 단추가 가로 열 수 줄(`ui-group-cards__bar`) 안 · 표 보기 = 머리 줄(`b09-estimation__bar`)
|
||||
- 단추 · 라벨 = `white-space: nowrap` · 도구 줄 = 줄바꿈 없음(`flex-wrap: nowrap`)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
|
||||
|
||||
B09 = "B09_Estimation/B09_Estimation_UI_Tabs_"
|
||||
DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0}
|
||||
HINT = "(() => { const h = document.createElement('p'); h.className = 'hint'; return h; })()"
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _panel(view: str, hint: str):
|
||||
"""고른 보기 · 안내 글로 그린 뒤 보기 바꿈 단추가 든 줄 · 그 줄의 열 수 단추."""
|
||||
return run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
f"\nchosen.set('dg', '{view}');"
|
||||
f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}},"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
|
||||
"\nconst out = renderGroupPanel(o);"
|
||||
"\nconst top = out.find((e) => e.all('[data-b09-group-view]').length || e.dataset.b09GroupView);"
|
||||
"\nconst btn = top.dataset.b09GroupView ? top : top.querySelector('[data-b09-group-view]');"
|
||||
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar') ?? top;"
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent,"
|
||||
" bar: bar.className, cols: bar.all('[data-group-cols]').map((b) => b.dataset.groupCols) }));",
|
||||
RAW,
|
||||
)
|
||||
|
||||
|
||||
def test_카드_보기_단추가_가로_열수_줄에():
|
||||
out = _panel("cards", "null")
|
||||
assert out["bar"] == "ui-group-cards__bar" and out["label"] == "표로 보기"
|
||||
assert out["cols"] == ["1", "2", "3", "4"] # 같은 줄에 열 수 단추 넷
|
||||
assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐
|
||||
|
||||
|
||||
def test_카드_보기_안내_글은_카드_위에_따로():
|
||||
out = _panel("cards", HINT)
|
||||
assert out["n"] == 2 and out["first"] == "hint"
|
||||
assert out["bar"] == "ui-group-cards__bar"
|
||||
|
||||
|
||||
def test_표_보기_단추는_머리_줄():
|
||||
out = _panel("table", "null")
|
||||
assert out["bar"] == "b09-estimation__bar" and out["label"] == "박스보기"
|
||||
assert out["cols"] == [] # 표 보기 = 열 수 단추 없음
|
||||
|
||||
|
||||
def test_CSS_한_줄_nowrap():
|
||||
cards = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8")
|
||||
assert "flex-wrap: nowrap" in _block(cards, ".ui-group-cards__bar")
|
||||
item = _block(
|
||||
cards,
|
||||
".ui-group-cards__bar .ui-btn,\n.ui-group-cards__label,\n.ui-group-cards__count",
|
||||
)
|
||||
assert "white-space: nowrap" in item and "flex: none" in item
|
||||
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
assert "white-space: nowrap" in _block(page, ".b09-estimation__bar .ui-btn")
|
||||
assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8")
|
||||
@@ -0,0 +1,187 @@
|
||||
"""185-3 공용 호버 정보 양식(`ui_template_hover_info.ts`) — 지연 · 뒤집기 · 초점 · 한 번에 하나 · 토큰.
|
||||
|
||||
Node 로 모듈을 그대로 돌림(작은 가짜 DOM · 타이머 손으로 돌림) · 자리 셈 `placeHoverInfo` 는 함수만.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS = ROOT / "config" / "node_modules" / "typescript"
|
||||
MOD = ROOT / "ui_template" / "ui_template_hover_info.ts"
|
||||
CSS = ROOT / "ui_template" / "ui_template_hover_info.css"
|
||||
|
||||
PRELUDE = f"""
|
||||
const fs = require("fs");
|
||||
const ts = require({json.dumps(str(TS))});
|
||||
const js = ts.transpileModule(fs.readFileSync({json.dumps(str(MOD))}, "utf8"), {{
|
||||
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
|
||||
}}).outputText;
|
||||
class Node {{}}
|
||||
class Element extends Node {{
|
||||
constructor(tag, sel) {{
|
||||
super(); this.tag = tag; this.sel = sel; this.parentElement = null; this.children = [];
|
||||
this.attrs = {{}}; this.on = {{}}; this.style = {{}}; this.hidden = false; this.isConnected = true;
|
||||
this.offsetWidth = 200; this.offsetHeight = 40; this.textContent = "";
|
||||
this.rect = {{ left: 100, top: 100, right: 180, bottom: 120 }};
|
||||
const set = new Set();
|
||||
this.classList = {{ set, toggle: (c, on) => (on ? set.add(c) : set.delete(c)), contains: (c) => set.has(c) }};
|
||||
}}
|
||||
appendChild(c) {{ c.parentElement = this; this.children.push(c); return c; }}
|
||||
contains(n) {{ for (let x = n; x; x = x.parentElement) if (x === this) return true; return false; }}
|
||||
closest(sel) {{ for (let x = this; x; x = x.parentElement) if (x.sel === sel) return x; return null; }}
|
||||
setAttribute(k, v) {{ this.attrs[k] = String(v); }}
|
||||
getAttribute(k) {{ return this.attrs[k] ?? null; }}
|
||||
removeAttribute(k) {{ delete this.attrs[k]; }}
|
||||
addEventListener(t, f) {{ (this.on[t] ||= []).push(f); }}
|
||||
removeEventListener(t, f) {{ this.on[t] = (this.on[t] || []).filter((g) => g !== f); }}
|
||||
getBoundingClientRect() {{ return this.rect; }}
|
||||
fire(type, extra = {{}}) {{
|
||||
const e = {{ type, target: this, ...extra }};
|
||||
for (let x = this; x; x = x.parentElement) (x.on[type] || []).forEach((f) => f(e));
|
||||
}}
|
||||
}}
|
||||
const document = new Element("#document");
|
||||
document.body = document.appendChild(new Element("body"));
|
||||
document.createElement = (t) => new Element(t);
|
||||
const window = {{ innerWidth: 1000, innerHeight: 800 }};
|
||||
let timers = [];
|
||||
let seq = 0;
|
||||
const fakeSet = (f, ms) => (timers.push({{ id: ++seq, f, ms }}), seq);
|
||||
const fakeClear = (id) => (timers = timers.filter((t) => t.id !== id));
|
||||
const tick = () => {{ const now = timers; timers = []; now.forEach((t) => t.f()); return now.map((t) => t.ms); }};
|
||||
const mod = {{ exports: {{}} }};
|
||||
new Function("exports", "module", "require", "document", "window", "Element", "Node",
|
||||
"setTimeout", "clearTimeout", js)(mod.exports, mod, () => ({{}}), document, window, Element, Node,
|
||||
fakeSet, fakeClear);
|
||||
const H = mod.exports;
|
||||
const box = () => document.body.children.filter((c) => c.attrs.role === "tooltip");
|
||||
const out = {{}};
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str):
|
||||
script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));"
|
||||
got = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def test_자리_아래가_기본_아래_모자라면_위로_뒤집기_좌우는_화면_안():
|
||||
got = _node(
|
||||
"""
|
||||
const view = { width: 1000, height: 800 };
|
||||
const size = { width: 200, height: 40 };
|
||||
const at = (l, t, r, b) => H.placeHoverInfo({ left: l, top: t, right: r, bottom: b }, size, view);
|
||||
out.below = at(100, 100, 180, 120);
|
||||
out.bottom = at(100, 760, 180, 780);
|
||||
out.right = at(950, 100, 990, 120);
|
||||
out.left = at(-30, 100, 20, 120);
|
||||
out.tight = at(100, 10, 180, 790);
|
||||
"""
|
||||
)
|
||||
assert got["below"] == {"x": 100, "y": 126, "above": False}
|
||||
assert got["bottom"] == {"x": 100, "y": 714, "above": True} # 760 - 6 - 40
|
||||
assert got["right"]["x"] == 1000 - 6 - 200 and not got["right"]["above"]
|
||||
assert got["left"]["x"] == 6
|
||||
assert got["tight"]["above"] is False # 위도 모자라면 아래 그대로
|
||||
|
||||
|
||||
def test_마우스_짧은_지연_뒤_뜸_떠나면_닫힘_aria():
|
||||
got = _node(
|
||||
"""
|
||||
const root = document.body.appendChild(new Element("table"));
|
||||
const cell = root.appendChild(new Element("td", "td"));
|
||||
const inner = cell.appendChild(new Element("span"));
|
||||
H.attachHoverInfo(root, (t) => (t === cell ? "물가정보 · 2026-07 · 670쪽" : null), "td");
|
||||
inner.fire("mouseover");
|
||||
out.beforeTick = box().length ? !box()[0].hidden : false;
|
||||
out.delays = tick();
|
||||
out.text = box()[0].textContent;
|
||||
out.shown = !box()[0].hidden;
|
||||
out.aria = cell.attrs["aria-describedby"] === box()[0].id;
|
||||
out.pos = [box()[0].style.left, box()[0].style.top];
|
||||
inner.fire("mouseout", { relatedTarget: cell }); // 칸 안에서 움직임 = 그대로
|
||||
out.stay = !box()[0].hidden;
|
||||
cell.fire("mouseout", { relatedTarget: root });
|
||||
out.gone = box()[0].hidden && !("aria-describedby" in cell.attrs);
|
||||
"""
|
||||
)
|
||||
assert got["beforeTick"] is False and got["delays"] == [350]
|
||||
assert got["text"] == "물가정보 · 2026-07 · 670쪽" and got["shown"] and got["aria"]
|
||||
assert got["pos"] == ["100px", "126px"]
|
||||
assert got["stay"] and got["gone"]
|
||||
|
||||
|
||||
def test_키보드_초점이면_지연_없이_뜸_화면_끝이면_뒤집힘():
|
||||
got = _node(
|
||||
"""
|
||||
const root = document.body.appendChild(new Element("div"));
|
||||
const btn = root.appendChild(new Element("button"));
|
||||
btn.rect = { left: 900, top: 770, right: 990, bottom: 790 };
|
||||
H.attachHoverInfo(btn, "단추 설명");
|
||||
btn.fire("focusin");
|
||||
out.delays = tick();
|
||||
const b = box()[0];
|
||||
out.text = b.textContent;
|
||||
out.above = b.classList.contains("is-above");
|
||||
out.pos = [b.style.left, b.style.top];
|
||||
btn.fire("focusout", { relatedTarget: null });
|
||||
out.gone = b.hidden;
|
||||
"""
|
||||
)
|
||||
assert got["delays"] == [0] and got["text"] == "단추 설명"
|
||||
assert got["above"] and got["pos"] == ["794px", "724px"]
|
||||
assert got["gone"]
|
||||
|
||||
|
||||
def test_한_번에_하나_빈_글은_안_뜸_Esc_누름_떼기():
|
||||
got = _node(
|
||||
"""
|
||||
const root = document.body.appendChild(new Element("div"));
|
||||
const a = root.appendChild(new Element("td", "td"));
|
||||
const b = root.appendChild(new Element("td", "td"));
|
||||
const c = root.appendChild(new Element("td", "td"));
|
||||
const stop = H.attachHoverInfo(root, (t) => (t === a ? "A" : t === b ? "B" : ""), "td");
|
||||
a.fire("focusin"); tick();
|
||||
b.fire("focusin"); tick();
|
||||
out.count = box().length;
|
||||
out.text = box()[0].textContent;
|
||||
out.aOff = !("aria-describedby" in a.attrs) && "aria-describedby" in b.attrs;
|
||||
c.fire("focusin"); tick();
|
||||
out.emptyHidden = box()[0].hidden;
|
||||
a.fire("focusin"); tick();
|
||||
document.fire("keydown", { key: "Escape" });
|
||||
out.esc = box()[0].hidden;
|
||||
a.fire("focusin"); tick(); a.fire("focusout", { relatedTarget: null });
|
||||
a.fire("mouseover"); tick();
|
||||
document.fire("pointerdown");
|
||||
out.press = box()[0].hidden;
|
||||
stop();
|
||||
a.fire("mouseover");
|
||||
out.afterStop = timers.length;
|
||||
"""
|
||||
)
|
||||
assert got["count"] == 1 and got["text"] == "B" and got["aOff"]
|
||||
assert got["emptyHidden"] and got["esc"] and got["press"]
|
||||
assert got["afterStop"] == 0
|
||||
|
||||
|
||||
def test_모양은_테마_토큰_밝음_어두움_같이_마우스_안_막음():
|
||||
css = CSS.read_text(encoding="utf-8")
|
||||
rule = css[css.index(".ui-hover-info {") :]
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule) # 색 글자 박지 않음
|
||||
for token in ("--color-surface-raised", "--color-border", "--color-text-body", "--z-toast"):
|
||||
assert f"var({token})" in rule
|
||||
assert "pointer-events: none" in rule and "position: fixed" in rule
|
||||
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8")
|
||||
dark = theme[theme.index(':root[data-theme="dark"]') :]
|
||||
assert "--color-canvas:" in dark and "--color-mist:" in dark # 어두움 = 토큰 바탕이 바뀜
|
||||
src = MOD.read_text(encoding="utf-8")
|
||||
assert (
|
||||
'import "./ui_template_hover_info.css";' in src and 'setAttribute("role", "tooltip")' in src
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""185-4 · 185-5 자재단가대비표 — 「출처」 열 없앰 · 출처 뜻 = 단가 칸 호버 · 견적서 글자 = 수기 견적 줄만.
|
||||
|
||||
Node 로 `buildCompare` 를 그대로 돌림(이웃 모듈은 작은 대역) · 값 · 저장 꼴(`user.materials`)은 그대로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS = ROOT / "config" / "node_modules" / "typescript"
|
||||
BASE = ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts"
|
||||
|
||||
PRELUDE = f"""
|
||||
const fs = require("fs");
|
||||
const ts = require({json.dumps(str(TS))});
|
||||
const js = ts.transpileModule(fs.readFileSync({json.dumps(str(BASE))}, "utf8"), {{
|
||||
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
|
||||
}}).outputText;
|
||||
const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적"];
|
||||
const col = (kind) => (key, label, a, b) =>
|
||||
({{ key, label, kind, group: Array.isArray(a) ? a : Array.isArray(b) ? b : undefined }});
|
||||
const opened = [];
|
||||
const stubs = {{
|
||||
"./B09_Estimation_Calc_Base": {{ SOURCES }},
|
||||
"./B09_Estimation_UI_Tabs_Base_Quote": {{ openQuotes: (key) => opened.push(key) }},
|
||||
"./B09_Estimation_UI_Tabs": {{
|
||||
money: col("number"), qty: col("number"), text: col(undefined),
|
||||
parseNumber: (t) => (t === "" ? null : Number(t)),
|
||||
}},
|
||||
}};
|
||||
const mod = {{ exports: {{}} }};
|
||||
new Function("exports", "module", "require", js)(mod.exports, mod, (p) => stubs[p]);
|
||||
const B = mod.exports;
|
||||
const out = {{}};
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str):
|
||||
script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));"
|
||||
got = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
ROWS = """
|
||||
const blank = Object.fromEntries(SOURCES.map((s) => [s, null]));
|
||||
const compare = [
|
||||
{ key: "MT1", name: "백호우티스", spec: "0.2㎡", unit: "개", applied_from: "유통물가",
|
||||
prices: { ...blank, 유통물가: 5880, 물가정보: 9200, 관급: 5000 },
|
||||
pages: { ...blank, 유통물가: "1094(26.7)", 물가정보: "부536(26.7)" } },
|
||||
{ key: "MO2", name: "자동차용경유", spec: "전국평균", unit: "L", applied_from: "유가",
|
||||
source: "유가전력 오피넷유가 D047", prices: { ...blank, 유가: 1846.39 }, pages: { ...blank, 유가: "2026-08-14" } },
|
||||
{ key: "B08:1", name: "견적 자재", spec: "", unit: "개", applied_from: "견적",
|
||||
prices: { ...blank, 견적: 12000 }, pages: { ...blank } },
|
||||
{ key: "B08:2", name: "파일만 남은 줄", spec: "", unit: "개", applied_from: null,
|
||||
prices: { ...blank }, pages: { ...blank } },
|
||||
];
|
||||
const user = { materials: { "B08:1": { quote: 12000, files: ["a.pdf", "b.pdf"] }, "B08:2": { files: ["c.pdf"] } } };
|
||||
let saved = null;
|
||||
const table = B.buildCompare({ sheets: { compare }, user, setUser: (u) => (saved = u) });
|
||||
"""
|
||||
|
||||
|
||||
def test_출처_열_없음_출처_묶음은_단가_하나_견적만_견적서():
|
||||
got = _node(
|
||||
ROWS
|
||||
+ """
|
||||
out.keys = table.columns.map((c) => c.key);
|
||||
out.labels = table.columns.filter((c) => c.group).map((c) => `${c.group[0]}/${c.label}`);
|
||||
out.rowKeys = Object.keys(table.rows[0]);
|
||||
"""
|
||||
)
|
||||
assert not [k for k in got["keys"] if k.startswith("g:")]
|
||||
assert got["labels"][:7] == [
|
||||
"관급/단가",
|
||||
"물가정보/단가",
|
||||
"물가자료/단가",
|
||||
"유통물가/단가",
|
||||
"거래가격/단가",
|
||||
"유가/단가",
|
||||
"견적/단가",
|
||||
]
|
||||
assert got["labels"][7] == "견적/견적서" and got["labels"].count("견적/견적서") == 1
|
||||
assert not [k for k in got["rowKeys"] if k.startswith("g:")]
|
||||
assert "applied_from" in got["keys"] # 적용 묶음의 「출처」(고른 출처 이름)는 그대로
|
||||
|
||||
|
||||
def test_출처_단가_칸_호버_글_숫자_뜻을_풀어_짧게():
|
||||
got = _node(
|
||||
ROWS
|
||||
+ """
|
||||
const tip = (i, k) => table.tip(i, k);
|
||||
out.tips = [tip(0, "p:유통물가"), tip(0, "p:물가정보"), tip(0, "p:관급"), tip(0, "p:거래가격"),
|
||||
tip(1, "p:유가"), tip(2, "p:견적"), tip(0, "name"), tip(0, "applied")];
|
||||
const r = (s, page) => ({ prices: { [s]: 100 }, pages: { [s]: page } });
|
||||
out.pages = [
|
||||
B.sourceInfo(r("물가자료", "하38(26.7)"), "물가자료"),
|
||||
B.sourceInfo(r("거래가격", "1권413(17.4)"), "거래가격"),
|
||||
B.sourceInfo(r("물가정보", "102(25.12)"), "물가정보"),
|
||||
B.sourceInfo(r("물가정보", "536(26적정)"), "물가정보"),
|
||||
B.sourceInfo(r("물가정보", null), "물가정보"),
|
||||
B.sourceInfo({ prices: { 물가정보: 0 }, pages: { 물가정보: "1(26.7)" } }, "물가정보"),
|
||||
B.sourceInfo({ ...r("유가", "2025-01-01"), source: "유가전력 한전 전기요금표" }, "유가"),
|
||||
B.sourceInfo(r("견적", null), "견적"),
|
||||
];
|
||||
"""
|
||||
)
|
||||
assert got["tips"] == [
|
||||
"유통물가 · 2026-07 · 1094쪽",
|
||||
"물가정보 · 2026-07 · 부록 536쪽",
|
||||
"관급 · 조달청 계약 단가",
|
||||
None, # 값 없는 칸 = 안 뜸
|
||||
"유가 · 오피넷 · 2026-08-14 수집",
|
||||
"견적 · 수기 입력 · 견적서 2 파일",
|
||||
None,
|
||||
None,
|
||||
]
|
||||
assert got["pages"] == [
|
||||
"물가자료 · 2026-07 · 하권 38쪽",
|
||||
"거래가격 · 2017-04 · 1권 413쪽",
|
||||
"물가정보 · 2025-12 · 102쪽",
|
||||
"물가정보 · 536(26적정)", # 못 푸는 꼴 = 원문 그대로
|
||||
"물가정보 · 면수 없음",
|
||||
None,
|
||||
"전기요금 · 한전 · 2025-01-01 시행",
|
||||
"견적 · 수기 입력",
|
||||
]
|
||||
|
||||
|
||||
def test_견적서_글자는_수기_견적_줄만_빈칸은_안_엶_값_저장_꼴_그대로():
|
||||
got = _node(
|
||||
ROWS
|
||||
+ """
|
||||
out.files = table.rows.map((r) => r.files);
|
||||
out.prices = table.rows.map((r) => r["p:물가정보"]);
|
||||
table.onCell(0, "files");
|
||||
table.onCell(1, "files");
|
||||
table.onCell(2, "files");
|
||||
table.onCell(3, "files");
|
||||
out.opened = opened;
|
||||
table.onEdit(1, "p:견적", "3000");
|
||||
out.saved = saved.materials;
|
||||
"""
|
||||
)
|
||||
assert got["files"] == ["", "", "2 파일", "1 파일"] # 파일이 남은 줄은 안 숨김
|
||||
assert got["prices"] == [9200, None, None, None]
|
||||
assert got["opened"] == ["B08:1", "B08:2"]
|
||||
assert got["saved"]["MO2"] == {"quote": 3000}
|
||||
assert got["saved"]["B08:1"] == {"quote": 12000, "files": ["a.pdf", "b.pdf"]}
|
||||
|
||||
|
||||
def test_Shell_칸_풍선은_공용_호버_정보():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text(encoding="utf-8")
|
||||
assert 'import { attachHoverInfo } from "@ui/ui_template_hover_info";' in shell
|
||||
assert '"tr[data-i] td",' in shell and "cell.title = tip" not in shell
|
||||
@@ -0,0 +1,73 @@
|
||||
"""PLAN 186-1 — 흙막이류 횡단 형상(랩탑 메인 프로젝트 No.14+12) 사본 재현.
|
||||
|
||||
자료: 검증_랩탑메인(226fc805) No.14+12.22 = 관(파형강관 D800) · 유입 L형 집수정 · 유출 흙막이
|
||||
(돌쌓기(찰) H1.3) · 유출 쪽 원지반이 노견보다 높아 벽이 묻힘(벽 윗면 849.99 · 원지반 852.24).
|
||||
|
||||
흠: 묻힌 유출 벽에 절토선이 둘 — 벽 바깥 윗모서리 절토(91-4 `withBackCut`)와 관 끝 3도 절토선
|
||||
(139-4). 둘 다 설계선(트림 사면)에 이어 붙어 선이 원지반까지 올랐다가 벽 앞으로 되내려 접힘
|
||||
(오프셋 13.45 → 14.59 → 13.69) → 카드 · B07 · 3D 모양이 꺾이고 서버 면적도 접힌 폐회로로 셈.
|
||||
|
||||
고침: 유출 끝 연결선(139-4)이 있으면 벽 모서리 절토를 잇지 않음(`_Culvert_Geom.ts`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_186_1_outlet_fold.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _one_way(offsets: list[float], outward: int) -> bool:
|
||||
"""설계선이 바깥으로만 나아감 — 되돌아가면 접힌 것."""
|
||||
return all((b - a) * outward >= -1e-9 for a, b in zip(offsets, offsets[1:]))
|
||||
|
||||
|
||||
def _no_peak(elevations: list[float]) -> bool:
|
||||
"""원지반까지 올랐다 되내리는 뾰족 봉우리 없음."""
|
||||
return not any(b > a and b > c for a, b, c in zip(elevations, elevations[1:], elevations[2:]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["stored", "auto"])
|
||||
def test_묻힌_유출_벽_설계선이_안_접힘(out: dict, case: str) -> None:
|
||||
"""저장값(d 9.0)도 자동 자리도 — 벽은 묻혀 있고 설계선은 한 방향."""
|
||||
study = out[case]
|
||||
assert study["wallTop"] < study["groundAtWall"] # 사본이 묻힌 벽을 재현
|
||||
assert _one_way(study["offsets"], study["outward"]), study["offsets"]
|
||||
assert _no_peak(study["elevations"]), study["elevations"]
|
||||
|
||||
|
||||
def test_저장값_사본_면적(out: dict) -> None:
|
||||
"""접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만."""
|
||||
assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3)
|
||||
assert out["stored"]["fill"] == 0
|
||||
assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3)
|
||||
|
||||
|
||||
def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None:
|
||||
"""91-4 자료(관 340 · 유출 = 절토 쪽) — 3도 선이 못 만나 복합경사 하나로 원지반까지."""
|
||||
study = out["cut340"]
|
||||
assert _one_way(study["offsets"], study["outward"]), study["offsets"]
|
||||
assert _no_peak(study["elevations"]), study["elevations"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""187-1 B05 종단 그래프 머리 줄 — [초기화]~▼ 단추 크기 = 오른쪽 [배수유역도] · [새 창] 크기.
|
||||
|
||||
- 기준값 = 오른쪽 두 단추의 계산 크기(`.ui-btn` 바탕 + 줄 단추 토큰) — 높이 16 · 글자 12 · 좌우 여백 8
|
||||
- 왼쪽 단추(`b05-route-profile__tool`) = 같은 토큰 · 고정 높이 · 최소 너비 없음
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _decl(block: str, prop: str) -> str:
|
||||
m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block)
|
||||
assert m, prop
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
THEME = _read("ui_template/ui_template_theme.css")
|
||||
BASE = _read("ui_template/ui_template_elements_styles.ts")
|
||||
LEFT = _block(_read("B05_Profile/B05_Profile_UI_Style.css"), ".b05-route-profile__tool")
|
||||
TOGGLE = _block(
|
||||
_read("B05_Profile/B05_Profile_UI_Style_Table.css"),
|
||||
".b05-route-profile__trailing > .b05-drainage__toggle",
|
||||
)
|
||||
POPOUT = _block(
|
||||
_read("A00_Common/design/design_page.css"),
|
||||
".b05-route-profile__trailing > .b05-route-profile__popout",
|
||||
)
|
||||
|
||||
|
||||
def _px(value: str) -> int:
|
||||
return int(re.fullmatch(r"(\d+)px", value).group(1))
|
||||
|
||||
|
||||
def _token(name: str) -> str:
|
||||
return _decl(THEME, name)
|
||||
|
||||
|
||||
def _size(block: str) -> dict:
|
||||
"""계산 크기 — padding · font-size 를 토큰으로 풀고 line-height 1 · 테두리 1px 로 높이를 셈."""
|
||||
pad = _decl(block, "padding")
|
||||
font = _decl(block, "font-size")
|
||||
if pad == "var(--btn-bar-padding)":
|
||||
pad = _token("--btn-bar-padding")
|
||||
if font == "var(--btn-bar-font-size)":
|
||||
font = _token("--btn-bar-font-size")
|
||||
v = re.fullmatch(r"(\d+)px var\(--spacing-8\)", pad).group(1)
|
||||
spacing8 = _px(_token("--spacing-8"))
|
||||
caption = _px(_token("--text-caption")) if font == "var(--text-caption)" else _px(font)
|
||||
return {"padV": int(v), "padH": spacing8, "font": caption, "height": caption + 2 * int(v) + 2}
|
||||
|
||||
|
||||
def test_기준값_오른쪽_단추_16_12_8():
|
||||
for block in (TOGGLE, POPOUT):
|
||||
assert _size(block) == {"padV": 1, "padH": 8, "font": 12, "height": 16}
|
||||
# 높이 계산의 바탕 = .ui-btn 줄 높이 1 · 테두리 1px
|
||||
assert re.search(r"\.ui-btn \{[^}]*line-height: 1;[^}]*border: 1px solid", BASE)
|
||||
|
||||
|
||||
def test_왼쪽_단추_높이_글자_여백이_오른쪽_기준값과_같음():
|
||||
assert _size(LEFT) == _size(TOGGLE) == _size(POPOUT)
|
||||
assert _decl(LEFT, "line-height") == "1"
|
||||
assert re.search(r"border:\s*1px solid", LEFT)
|
||||
|
||||
|
||||
def test_왼쪽_단추에_옛_고정_크기_없음():
|
||||
assert "height:" not in LEFT.replace("line-height", "")
|
||||
assert "min-width" not in LEFT
|
||||
assert "9px" not in LEFT
|
||||
|
||||
|
||||
def test_세_단추_모두_같은_토큰():
|
||||
for block in (LEFT, TOGGLE, POPOUT):
|
||||
assert _decl(block, "padding") == "var(--btn-bar-padding)"
|
||||
assert _decl(block, "font-size") == "var(--btn-bar-font-size)"
|
||||
assert _decl(LEFT, "font-family") == "var(--font-body)" # .ui-btn 바탕과 같은 글꼴
|
||||
|
||||
|
||||
def test_토큰은_theme_한_곳():
|
||||
assert _token("--btn-bar-padding") == "1px var(--spacing-8)"
|
||||
assert _token("--btn-bar-font-size") == "var(--text-caption)"
|
||||
assert _px(_token("--spacing-8")) == 8 and _px(_token("--text-caption")) == 12
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 188-1 — 종단(B05) ↔ 횡단(B06) 오갈 때 마지막 고름 하나만 이김.
|
||||
|
||||
원인: 종단 측점선 · 3D 띠 · 표에서 고른 일반 측점은 고름 한 칸(`structure-pick`)에 안 적혀
|
||||
(`syncIrregularSelection` 이 일반 측점이면 바로 끝남) 옛 배관 고름이 남아 횡단이 배관 카드로 감.
|
||||
횡단 → 종단은 관 · 구조물이 없는 자리면 그래프 측점선을 풀어 버림(`selectGraphAt` = null).
|
||||
실제 TS 를 `helper_188_1_last_pick.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_188_1_last_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def test_배관_다음_일반_측점이면_횡단은_일반_측점(out) -> None:
|
||||
assert out["pipeThenStation"] == "s20"
|
||||
assert out["pillThenStation"] == "s20" # 알약으로 고른 배관도
|
||||
|
||||
|
||||
def test_일반_측점_다음_배관이면_횡단은_배관(out) -> None:
|
||||
assert out["stationThenPipe"] == "irregular:p1"
|
||||
|
||||
|
||||
def test_구조물을_끼워도_마지막_고름이_이김(out) -> None:
|
||||
assert out["structureThenStation"] == "s100"
|
||||
assert out["stationThenStructure"] == "s60" # 옹벽 = 시작 측점 카드
|
||||
assert out["structureThenPipe"] == "irregular:p1"
|
||||
|
||||
|
||||
def test_고름을_풀면_비우고_모르는_측점은_안_건드림(out) -> None:
|
||||
assert out["released"] is None
|
||||
assert out["unknownKeeps"] == 20
|
||||
|
||||
|
||||
def test_횡단_일반_측점은_종단_측점선과_3D_띠로(out) -> None:
|
||||
station = out["b06Station"]
|
||||
assert station["graph"] == {"station": "s40", "mark": None}
|
||||
assert station["marker"] == "s40"
|
||||
assert station["pick"] == {"chainageM": 40, "structureId": None}
|
||||
|
||||
|
||||
def test_횡단_배관은_종단_배관_측점선_측점선_없는_관은_알약(out) -> None:
|
||||
assert out["b06Pipe"]["graph"]["station"] == "irregular:p1"
|
||||
assert out["b06BarePipe"]["graph"] == {"station": None, "mark": "pipe-50.00"}
|
||||
|
||||
|
||||
def test_횡단_구조물은_종단에서_그_구조물을_열고_고름_한_칸을_안_덮음(out) -> None:
|
||||
structure = out["b06Structure"]
|
||||
assert structure["opened"] == ["wall"]
|
||||
# 되살리며 3D 띠 리스너가 되불러도(동기화 중) 구조물 id · 부재키가 남음
|
||||
assert structure["pick"] == {"chainageM": 60, "structureId": "wall", "partKey": "own"}
|
||||
|
||||
|
||||
def test_종단_횡단을_오가도_마지막_고름(out) -> None:
|
||||
assert out["roundTrip"] == ["s20", "s80", "irregular:p1", "s0"]
|
||||
|
||||
|
||||
def test_그래프_표적_차례(out) -> None:
|
||||
target = out["graphTarget"]
|
||||
assert target["byId"] == {"stationId": "s20"}
|
||||
assert target["bare"] == {"pipeChainageM": 50}
|
||||
assert target["station"] == {"stationId": "s100"}
|
||||
assert target["far"] == {"stationId": None}
|
||||
assert target["none"] == {"stationId": None}
|
||||
|
||||
|
||||
def test_종단_보기_배선() -> None:
|
||||
page = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "regularChainage:" in page
|
||||
assert "remember: (chainageM) => rememberPick(chainageM, null)" in page
|
||||
assert "graphTargetAt(id, chainageM, lastPipes ?? [], sections)" in page
|
||||
@@ -10,11 +10,19 @@
|
||||
|
||||
.ui-group-cards__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 185-2 도구 줄 = 한 줄 · 단추 · 글 줄바꿈 없음 */
|
||||
.ui-group-cards__bar .ui-btn,
|
||||
.ui-group-cards__label,
|
||||
.ui-group-cards__count {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-group-cards__cols {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* =============================================================================
|
||||
* 호버 정보 상자 공용 모양(`ui_template_hover_info.ts` · PLAN 185-3) — 화면에 하나 · fixed ·
|
||||
* 색 = 테마 토큰(밝음 · 어두움 같이) · 마우스를 안 막음
|
||||
* ========================================================================== */
|
||||
|
||||
.ui-hover-info {
|
||||
position: fixed;
|
||||
z-index: var(--z-toast);
|
||||
max-width: min(320px, calc(100vw - 12px));
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text-body);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-hover-info[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/* =============================================================================
|
||||
* 호버 정보 — 프로그램 전역 공용 「마우스를 올리면 짧은 추가 정보」 상자(PLAN 185-3)
|
||||
*
|
||||
* attachHoverInfo(root, text, selector?) — root 안 selector 에 맞는 것(없으면 root 자신)에
|
||||
* 마우스(짧은 지연) · 키보드 초점(바로)이 오면 상자 · 떠나면 · 누름 · Esc · 스크롤이면 닫힘 · 돌려준 함수 = 떼기
|
||||
* text = 글 하나 또는 (대상) → 글(빈 값 = 안 띄움) · 표 칸 · 단추 어디나 같은 한 벌
|
||||
* 상자 = 화면에 하나(새로 뜨면 앞 것 갈음) · 대상 아래가 기본 · 아래가 모자라면 위로 뒤집고
|
||||
* 좌우는 화면 안으로 당김(`placeHoverInfo`) · 색 = 테마 토큰(밝음 · 어두움 같이)
|
||||
* 옛 title 풍선은 그대로 둠 — 새 자리부터 이 양식(B09 칸 풍선 `tip` 이 첫 사용처)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_hover_info.css";
|
||||
|
||||
export type HoverText = string | ((target: HTMLElement) => string | null | undefined);
|
||||
|
||||
/** 뜨기까지 지연(ms) — 지나가는 마우스엔 안 뜸 */
|
||||
export const HOVER_DELAY_MS = 350;
|
||||
/** 대상과 상자 사이 · 화면 끝 여백(px) */
|
||||
const GAP = 6;
|
||||
|
||||
interface Box {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** 상자 자리 — 대상 아래(왼쪽 맞춤)가 기본 · 아래가 모자라고 위가 넉넉하면 위로 · 좌우 화면 안 */
|
||||
export function placeHoverInfo(
|
||||
anchor: Box,
|
||||
size: { width: number; height: number },
|
||||
view: { width: number; height: number },
|
||||
): { x: number; y: number; above: boolean } {
|
||||
const below = anchor.bottom + GAP;
|
||||
const above = below + size.height > view.height - GAP && anchor.top - GAP - size.height >= GAP;
|
||||
const y = above ? anchor.top - GAP - size.height : below;
|
||||
const x = Math.max(GAP, Math.min(anchor.left, view.width - GAP - size.width));
|
||||
return { x, y, above };
|
||||
}
|
||||
|
||||
let box: HTMLDivElement | null = null;
|
||||
let owner: HTMLElement | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 지연 중인 대상 — 같은 대상 안에서 움직여도 지연을 새로 안 셈 */
|
||||
let pending: HTMLElement | null = null;
|
||||
let globalBound = false;
|
||||
|
||||
function shared(): HTMLDivElement {
|
||||
if (box?.isConnected) return box;
|
||||
box = document.createElement("div");
|
||||
box.className = "ui-hover-info";
|
||||
box.id = "ui-hover-info";
|
||||
box.setAttribute("role", "tooltip");
|
||||
box.hidden = true;
|
||||
document.body.appendChild(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 떠 있는 상자를 닫음(대상이 주면 그 대상 것만) */
|
||||
export function hideHoverInfo(target?: HTMLElement): void {
|
||||
if (target && target !== owner) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
pending = null;
|
||||
owner?.removeAttribute("aria-describedby");
|
||||
owner = null;
|
||||
if (box) box.hidden = true;
|
||||
}
|
||||
|
||||
function show(target: HTMLElement, text: string): void {
|
||||
const el = shared();
|
||||
owner?.removeAttribute("aria-describedby");
|
||||
owner = target;
|
||||
el.textContent = text;
|
||||
el.hidden = false;
|
||||
const r = target.getBoundingClientRect();
|
||||
const at = placeHoverInfo(
|
||||
r,
|
||||
{ width: el.offsetWidth, height: el.offsetHeight },
|
||||
{ width: window.innerWidth, height: window.innerHeight },
|
||||
);
|
||||
el.style.left = `${at.x}px`;
|
||||
el.style.top = `${at.y}px`;
|
||||
el.classList.toggle("is-above", at.above);
|
||||
target.setAttribute("aria-describedby", el.id);
|
||||
}
|
||||
|
||||
function bindGlobal(): void {
|
||||
if (globalBound) return;
|
||||
globalBound = true;
|
||||
document.addEventListener("keydown", (e) => e.key === "Escape" && hideHoverInfo());
|
||||
// 누름 = 닫음(누름 뒤 다시 그려져 대상이 사라져도 상자가 안 남음)
|
||||
document.addEventListener("pointerdown", () => hideHoverInfo(), true);
|
||||
document.addEventListener("scroll", () => hideHoverInfo(), true);
|
||||
}
|
||||
|
||||
export function attachHoverInfo(root: HTMLElement, text: HoverText, selector?: string): () => void {
|
||||
bindGlobal();
|
||||
const find = (node: EventTarget | null): HTMLElement | null => {
|
||||
if (!(node instanceof Element)) return null;
|
||||
const hit = selector ? node.closest<HTMLElement>(selector) : root;
|
||||
return hit && root.contains(hit) ? hit : null;
|
||||
};
|
||||
const enter = (event: Event, delay: number): void => {
|
||||
const target = find(event.target);
|
||||
if (!target || target === owner || (timer && target === pending)) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
pending = target;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const value = typeof text === "function" ? text(target) : text;
|
||||
if (value) show(target, value);
|
||||
else hideHoverInfo();
|
||||
}, delay);
|
||||
};
|
||||
const leave = (event: Event): void => {
|
||||
const target = find(event.target);
|
||||
const next = (event as MouseEvent | FocusEvent).relatedTarget;
|
||||
if (!target || (next instanceof Node && target.contains(next))) return;
|
||||
if (target === owner || target === pending) hideHoverInfo();
|
||||
};
|
||||
const over = (e: Event): void => enter(e, HOVER_DELAY_MS);
|
||||
const focus = (e: Event): void => enter(e, 0);
|
||||
root.addEventListener("mouseover", over);
|
||||
root.addEventListener("mouseout", leave);
|
||||
root.addEventListener("focusin", focus);
|
||||
root.addEventListener("focusout", leave);
|
||||
return () => {
|
||||
root.removeEventListener("mouseover", over);
|
||||
root.removeEventListener("mouseout", leave);
|
||||
root.removeEventListener("focusin", focus);
|
||||
root.removeEventListener("focusout", leave);
|
||||
if (owner && root.contains(owner)) hideHoverInfo();
|
||||
};
|
||||
}
|
||||
@@ -162,6 +162,10 @@
|
||||
--control-height: 42px;
|
||||
--control-height-sm: 30px;
|
||||
|
||||
/* 줄 위 작은 단추 한 규격 — 종단 머리 줄 [초기화]~▼ · [배수유역도] · [새 창] 이 함께 씀(높이 = 글자 12 + 여백 2 + 테두리 2) */
|
||||
--btn-bar-padding: 1px var(--spacing-8);
|
||||
--btn-bar-font-size: var(--text-caption);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 6. 그림자 (Shadows) — 블루틴트, 서브틀 (최대 32px blur / 6% opacity)
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user