Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
buildCrossSvg,
|
||||
@@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
|
||||
/** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */
|
||||
let drawn: CrossPreviewResponse | null = null;
|
||||
let size = { width: 0, height: 0 };
|
||||
let zoom: ZoomPanState | undefined;
|
||||
function draw(preview: CrossPreviewResponse): void {
|
||||
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
|
||||
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
|
||||
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
|
||||
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
|
||||
box.replaceChildren(plot.svg);
|
||||
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
|
||||
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
||||
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
initial: zoom,
|
||||
onChange: (state) => (zoom = state),
|
||||
});
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
const width = Math.round(rect.width);
|
||||
const height = Math.round(rect.height);
|
||||
if (!drawn || width === 0 || (width === size.width && height === size.height)) return;
|
||||
draw(drawn);
|
||||
});
|
||||
return {
|
||||
root,
|
||||
show(preview, intervalM) {
|
||||
box.replaceChildren();
|
||||
drawn = preview;
|
||||
zoom = undefined;
|
||||
if (!preview) {
|
||||
station.textContent = "";
|
||||
foot.textContent = empty;
|
||||
@@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane {
|
||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||
station.textContent = formatStation(preview.chainage_m, intervalM);
|
||||
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
|
||||
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
const plot = buildCrossSvg(
|
||||
preview,
|
||||
Math.max(Math.round(rect.width), 240),
|
||||
Math.max(Math.round(rect.height), 150),
|
||||
);
|
||||
box.append(plot.svg);
|
||||
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
||||
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
});
|
||||
draw(preview);
|
||||
foot.textContent = summarizeCross(preview);
|
||||
},
|
||||
wait(text) {
|
||||
box.replaceChildren();
|
||||
drawn = null;
|
||||
foot.textContent = text;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
|
||||
.b05-routeedit__cross-key {
|
||||
fill: var(--color-text-secondary, #94a3b8);
|
||||
font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-key.is-design {
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 접힌 패널은 얇은 띠 */
|
||||
/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 공용 접기 카드(접힌 카드 = 제목 행) */
|
||||
.m01lab-side {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-side__card,
|
||||
.m01lab-side__slot {
|
||||
flex: 0 0 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-side__slot.is-open {
|
||||
flex: 1 1 auto;
|
||||
/* 카드 제목 행이 접기 단추 — 흐름도 패널 자체 접기 단추 · 테두리는 뺌 */
|
||||
.m01lab-side .m02-dfp__toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 접힌 흐름도 자리 — 띠만(기다림 글 · 변환 알림은 숨김) */
|
||||
.m01lab-side__slot:not(.is-open) > :not(.m01lab-flow__panel, .m01lab-src) {
|
||||
display: none;
|
||||
.m01lab-side .m02-dfp {
|
||||
border-left: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.m01lab-src {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-src__body {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Side.ts
|
||||
* 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절)
|
||||
* 둘 중 하나만 열림(하나 열면 다른 하나 접힘) · 둘 다 접기 됨 · 접힌 패널 = 얇은 띠(머리 단추 그대로)
|
||||
* 공용 접기 카드(`ui-collapsible` · 제목 행 누름 · 캐럿) 두 장 — attachCollapsible 토글 위에 하나만 열림 규칙
|
||||
* 둘 다 접기 됨 · 접힌 카드 = 제목 행만
|
||||
* 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
@@ -14,9 +16,8 @@ import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Side.css";
|
||||
|
||||
const TEXT = {
|
||||
Toggle: ["원문", "Source"],
|
||||
Open: ["원문 펴기", "Open the source panel"],
|
||||
Close: ["원문 접기", "Collapse the source panel"],
|
||||
Flow: ["흐름도", "Flow"],
|
||||
Source: ["원문", "Source"],
|
||||
Loading: ["원문 찾는 중", "Looking up the source"],
|
||||
None: ["원문 연결 없음", "No linked source"],
|
||||
From: ["출처", "Source"],
|
||||
@@ -27,8 +28,8 @@ const TEXT = {
|
||||
const ts = (key: keyof typeof TEXT, v = ""): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
|
||||
|
||||
/** 접힌 띠 너비 — M02_DF_Panel 의 STRIP_WIDTH 와 같음(패널 모듈은 늦게 받으므로 따로 둠) */
|
||||
const STRIP = 40;
|
||||
/** 둘 다 접힘 — 제목 행 둘이 들어가는 칸 너비 */
|
||||
const FOLDED = 140;
|
||||
|
||||
export type PanelPick = "flow" | "source" | "none";
|
||||
|
||||
@@ -86,21 +87,28 @@ export function buildLogicSide(split: SplitHandle): LogicSide {
|
||||
const flowSlot = el("div", { className: "m01lab-side__slot" });
|
||||
const body = el("div", { className: "m01lab-src__body" });
|
||||
const title = el("strong", { className: "m02-dfp__title" });
|
||||
const toggle = createButton({
|
||||
label: ts("Toggle"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => pick(now === "source" ? "none" : "source"),
|
||||
});
|
||||
toggle.classList.add("m02-dfp__toggle");
|
||||
const head = el("header", { className: "m02-dfp__head", children: [toggle, title] });
|
||||
const open = el("div", { className: "m02-dfp__open", children: [head, body] });
|
||||
const strip = el("div", { className: "m02-dfp__strip" });
|
||||
const source = el("aside", { className: "m02-dfp m01lab-src", children: [open, strip] });
|
||||
const sourceSlot = el("div", { className: "m01lab-side__slot", children: [source] });
|
||||
split.right.replaceChildren(
|
||||
el("div", { className: "m01lab-side", children: [flowSlot, sourceSlot] }),
|
||||
const card = (label: string, inner: HTMLElement): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01lab-side__card ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: label }), inner],
|
||||
});
|
||||
const flowCard = card(ts("Flow"), flowSlot);
|
||||
const sourceCard = card(
|
||||
ts("Source"),
|
||||
el("div", { className: "m01lab-src", children: [title, body] }),
|
||||
);
|
||||
const side = el("div", {
|
||||
className: "m01lab-side ui-sidebar-panel",
|
||||
children: [flowCard, sourceCard],
|
||||
});
|
||||
attachCollapsible(side);
|
||||
// 공용 토글 뒤 — 연 카드만 남기고 다른 카드는 접음(접었으면 둘 다 닫힘)
|
||||
side.addEventListener("click", (ev) => {
|
||||
const at = (ev.target as HTMLElement).closest(".ui-collapsible__title")?.parentElement;
|
||||
if (at !== flowCard && at !== sourceCard) return;
|
||||
pick(at.classList.contains("is-collapsed") ? "none" : at === flowCard ? "flow" : "source");
|
||||
});
|
||||
split.right.replaceChildren(side);
|
||||
|
||||
let now = readPick();
|
||||
let flowPx: number | null = null;
|
||||
@@ -108,14 +116,22 @@ export function buildLogicSide(split: SplitHandle): LogicSide {
|
||||
let want = "";
|
||||
let shown: string | null = null;
|
||||
|
||||
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 띠 · 둘 다 접힘 = 띠 둘 */
|
||||
/** 카드 안팎 여백(좌우 안쪽 + 테두리) — 흐름도 패널 너비에 더함 */
|
||||
const pad = (): number => {
|
||||
const s = getComputedStyle(flowCard);
|
||||
return ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce(
|
||||
(n, k) => n + (parseFloat(s[k as "paddingLeft"]) || 0),
|
||||
0,
|
||||
);
|
||||
};
|
||||
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 카드 여백 · 둘 다 접힘 = 제목 행 */
|
||||
const width = (): void =>
|
||||
split.setRight(
|
||||
now === "source" || (now === "flow" && flowPx === null)
|
||||
? null
|
||||
: now === "flow"
|
||||
? (flowPx ?? 0) + STRIP
|
||||
: STRIP * 2,
|
||||
? (flowPx ?? 0) + pad()
|
||||
: FOLDED,
|
||||
);
|
||||
|
||||
const load = (): void => {
|
||||
@@ -149,21 +165,12 @@ export function buildLogicSide(split: SplitHandle): LogicSide {
|
||||
});
|
||||
};
|
||||
|
||||
const fold = (on: boolean): void => {
|
||||
open.hidden = on;
|
||||
strip.hidden = !on;
|
||||
(on ? strip : head).prepend(toggle);
|
||||
toggle.setAttribute("aria-expanded", String(!on));
|
||||
toggle.title = ts(on ? "Open" : "Close");
|
||||
};
|
||||
|
||||
const pick = (next: PanelPick): void => {
|
||||
if (next !== now) writeStateRaw("m01-logic-panel", next);
|
||||
now = next;
|
||||
flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠
|
||||
flowSlot.classList.toggle("is-open", now === "flow");
|
||||
sourceSlot.classList.toggle("is-open", now === "source");
|
||||
fold(now !== "source");
|
||||
flowCard.classList.toggle("is-collapsed", now !== "flow");
|
||||
sourceCard.classList.toggle("is-collapsed", now !== "source");
|
||||
width();
|
||||
load();
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemList.ts
|
||||
* 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나
|
||||
* 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
|
||||
* 구조물 항목 목록 하나(PLAN 39-5) — 복사 생성 · 항목 하나(도면 · 일위대가 · 면적산출) 복사가 같이 씀.
|
||||
* 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
|
||||
* (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음).
|
||||
* 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다.
|
||||
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
|
||||
@@ -142,22 +142,6 @@ export function pageOf<R>(
|
||||
export const loadItems = async (layer: Layer, owner: string | null): Promise<ItemRow[]> =>
|
||||
(await fetchTree(layer, owner)).items as ItemRow[];
|
||||
|
||||
/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */
|
||||
export function buildAllItems(
|
||||
kind: ListKind,
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
onOpen: (row: ItemRow) => void,
|
||||
): HTMLElement {
|
||||
const list = buildItemList();
|
||||
loadItems(layer, owner).then(
|
||||
(rows) => list.show(rows, ITEM_COLS[kind], onOpen),
|
||||
(error: unknown) =>
|
||||
list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
return list.root;
|
||||
}
|
||||
|
||||
export interface ItemListHandle {
|
||||
root: HTMLElement;
|
||||
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
|
||||
|
||||
@@ -143,10 +143,6 @@ export function headRenameEdit(col: StructureColumn, level: number, name: string
|
||||
export const columnHead = (col: StructureColumn): string =>
|
||||
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id;
|
||||
|
||||
/** 번호가 맨 앞 — 「SD010101 돌기슭막이 H=1.0」(번호가 없으면 이름만) */
|
||||
export const structureLabel = (number: string, col: StructureColumn): string =>
|
||||
[number, columnHead(col)].filter(Boolean).join(" ");
|
||||
|
||||
/** 면적산출 편집 화면 아래 알약 막대 — 그 열 일위대가의 로직 물량 「이름|단위」 + 자재 「자재|MT키」
|
||||
* (서버 pills 길 · 39-4 · 다 `고정` — 더하기 · 빼기는 일위대가 쪽).
|
||||
* 알약이 없으면 까닭 글(`notice`)을 같이 돌려줌 */
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { navigateTo, type MountedPage } from "../A00_Common/router";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
accessFor,
|
||||
buildReadOnlyBar,
|
||||
@@ -40,8 +40,7 @@ import {
|
||||
seesOthers,
|
||||
} from "./M02_MasterTemplete_TreePanel_Roots";
|
||||
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side";
|
||||
import { buildAllItems } from "./M02_MasterTemplete_ItemList";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
@@ -49,13 +48,6 @@ const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴
|
||||
const NEW_ASK =
|
||||
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
|
||||
|
||||
/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */
|
||||
const ALL_TITLE = {
|
||||
structure: "M02_KindStructure",
|
||||
labor: "M02_KindLabor",
|
||||
area: "M02_KindArea",
|
||||
} as const satisfies Record<AllKind, string>;
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const entry = entryFromHash();
|
||||
@@ -97,9 +89,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
canEdit: canEdit(layer, isAdmin),
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: () => main.settle(),
|
||||
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호
|
||||
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표
|
||||
onChanged: async (colId) => {
|
||||
await Promise.all([main.openTable(layer, colId, true), side.refresh()]);
|
||||
await main.openTable(layer, colId, true);
|
||||
},
|
||||
});
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
@@ -110,7 +102,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
|
||||
);
|
||||
};
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
@@ -193,20 +185,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
// 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`)
|
||||
onAll: (kind) =>
|
||||
void main.showView(
|
||||
`${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`,
|
||||
buildAllItems(kind, layer, owner, (row) => {
|
||||
void main.open({
|
||||
layer,
|
||||
projectId: null,
|
||||
kind: kind as Kind,
|
||||
name: row.열,
|
||||
label: row.이름,
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
|
||||
* 면적산출 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 일위대가번 SU · 면적번 SA + 같은 뒷자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」).
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
@@ -22,23 +19,15 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
deleteTemplate,
|
||||
fetchStructureNumbers,
|
||||
listTemplates,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type StructureNumbers,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
STRUCTURE_TABLE,
|
||||
columnHead,
|
||||
isStructureColumn,
|
||||
structureLabel,
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
@@ -47,21 +36,6 @@ const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
{ kind: "drawing", label: () => L("M02_KindDrawing") },
|
||||
];
|
||||
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 ·
|
||||
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */
|
||||
type ColumnKind = "structure" | "labor" | "area";
|
||||
const COLUMN_KINDS: {
|
||||
kind: ColumnKind;
|
||||
key: keyof StructureNumbers;
|
||||
label: () => string;
|
||||
all: boolean;
|
||||
}[] = [
|
||||
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
|
||||
{ kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true },
|
||||
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
|
||||
];
|
||||
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
|
||||
export type AllKind = "structure" | "labor" | "area";
|
||||
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
|
||||
const STUB_DRAWING = {
|
||||
format: 6,
|
||||
@@ -84,8 +58,6 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */
|
||||
onAll?: (kind: AllKind) => void;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -111,10 +83,6 @@ const failed = (error: unknown): void =>
|
||||
export function buildSide(opt: SideOptions): SideHandle {
|
||||
const layer = opt.layer;
|
||||
let items: TemplateInfo[] = [];
|
||||
let columns: StructureColumn[] = [];
|
||||
let numbers: Record<string, StructureNumbers> = {};
|
||||
/** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */
|
||||
let allPicked: AllKind | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
|
||||
@@ -125,9 +93,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const bodies = new Map<Kind, HTMLElement>(
|
||||
KINDS.map((k) => [k.kind, el("div", { className: "m02-side__items ui-sidebar-list" })]),
|
||||
);
|
||||
const columnBodies = COLUMN_KINDS.map(() =>
|
||||
el("div", { className: "m02-side__items ui-sidebar-list" }),
|
||||
);
|
||||
/** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */
|
||||
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
|
||||
const actionRow = (kind: Kind): HTMLElement[] => {
|
||||
@@ -146,7 +111,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (practice?.clear(), (allPicked = null), paint()),
|
||||
onOpen: () => (practice?.clear(), paint()),
|
||||
});
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
@@ -155,17 +120,13 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
});
|
||||
return sec;
|
||||
};
|
||||
const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i]));
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
|
||||
const summary = section(
|
||||
L("M02_KindTable"),
|
||||
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
|
||||
el("hr", { className: "m02-side__rule" }),
|
||||
...columnSections,
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
summary.classList.add("m02-side__summary");
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
@@ -183,7 +144,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
? buildPracticeSection({
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (options.clear(), (allPicked = null), paint()),
|
||||
onOpen: () => (options.clear(), paint()),
|
||||
})
|
||||
: null;
|
||||
const listHost = el("div", {
|
||||
@@ -234,94 +195,27 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */
|
||||
function allRow(kind: AllKind): HTMLElement {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`,
|
||||
text: L("M02_ItemsAll"),
|
||||
attrs: { type: "button", "data-all": kind },
|
||||
});
|
||||
b.addEventListener("click", () => void selectAll(kind));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
|
||||
function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void {
|
||||
const { kind, key } = k;
|
||||
const head = k.all ? [allRow(kind as AllKind)] : [];
|
||||
body.replaceChildren(
|
||||
...head,
|
||||
...(columns.length
|
||||
? columns.map((col) => {
|
||||
const num = numbers[col.id]?.[key] || L("M02_StructureNone");
|
||||
const b = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`,
|
||||
text: structureLabel(num, col),
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
b.addEventListener("click", () => void selectColumn(col, kind));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
);
|
||||
}
|
||||
const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k));
|
||||
|
||||
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
|
||||
function paintButtons(): void {
|
||||
const cur = currentInfo();
|
||||
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
|
||||
}
|
||||
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
/** 고름 표시는 여기서만 — 목록 · 옵션 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
if (opt.getOpen()) allPicked = null;
|
||||
paintList();
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
}
|
||||
|
||||
async function selectAll(kind: AllKind): Promise<void> {
|
||||
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
|
||||
options.clear();
|
||||
practice?.clear();
|
||||
allPicked = kind;
|
||||
opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림
|
||||
paint();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
|
||||
}
|
||||
|
||||
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
|
||||
if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return;
|
||||
// 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」)
|
||||
opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) });
|
||||
}
|
||||
|
||||
async function refreshStructure(): Promise<void> {
|
||||
try {
|
||||
const [table, nums] = await Promise.all([
|
||||
readTemplate(layer, "table", STRUCTURE_TABLE, null),
|
||||
fetchStructureNumbers(),
|
||||
]);
|
||||
const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[];
|
||||
columns = all.filter(isStructureColumn);
|
||||
numbers = nums;
|
||||
} catch (error) {
|
||||
columns = [];
|
||||
numbers = {};
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
paintColumns();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
paintButtons();
|
||||
@@ -334,7 +228,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
await refreshStructure();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
|
||||
@@ -10,18 +10,6 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 구조물 집계 양식 하위 컨테이너(구조물 도면 · 면적산출 · 재료산출 · 자재 조합) — 들여 씀 */
|
||||
.m02-side__summary > .ui-sidebar-card {
|
||||
margin-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */
|
||||
.m02-side__rule {
|
||||
margin: var(--spacing-8) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
|
||||
.m02-practice {
|
||||
display: flex;
|
||||
|
||||
@@ -64,17 +64,22 @@ def test_표_부품_combo_선택():
|
||||
assert "combo?: { onHeadEdit?: (colId: string, level: number, name: string) => void };" in sheet
|
||||
assert '...(ro || opts.combo ? [] : [el("p", { className: "ui-sheet__hint"' in sheet
|
||||
assert (
|
||||
"filter((k) => !opts.combo || k !== KEY_UNIT)" in sheet
|
||||
) # 방향키가 숨은 단위 줄을 안 밟음
|
||||
"const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode);" in sheet
|
||||
) # 방향키가 숨은 단위 · 번호 줄을 안 밟음
|
||||
assert 'head?.classList.add("is-head-picked")' in sheet
|
||||
assert "if (th && opts.combo?.onHeadEdit) editComboHead(th);" in sheet
|
||||
assert "if (opts.combo && sel.r === KEY_UNIT)" in sheet # F2 · Enter = 머리 이름 고침
|
||||
render = _text(SHEET / "ui_template_sheet_render.ts")
|
||||
assert "children: state.combo ? rows : [...rows, unit]" in render # 단위 줄 없음
|
||||
assert "if (state.combo) fitMeta(state, cols, mins);" in render
|
||||
# 45-3 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만)
|
||||
assert (
|
||||
'...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : [])'
|
||||
in render
|
||||
)
|
||||
assert "fitMeta" not in render
|
||||
assert "열너비: {}" in render # 저장 폭 안 씀 — 자동
|
||||
css = _text(SHEET / "ui_template_sheet.css")
|
||||
assert ".ui-sheet--combo .ui-sheet__meta td" in css and "th.is-head-picked" in css
|
||||
assert ".ui-sheet--combo .ui-sheet__meta td" not in css and "th.is-head-picked" in css
|
||||
|
||||
|
||||
def test_머리_고침은_쓸_수_있는_층만_나무로():
|
||||
|
||||
@@ -29,24 +29,30 @@ def test_나무_부품에_일위대가_전용_칸_없음():
|
||||
assert gone not in panel, gone
|
||||
|
||||
|
||||
def test_하위_차례_전체_항목_이름():
|
||||
def test_좌측_하위_컨테이너와_전체_항목_길_없음():
|
||||
"""45-2 — 구조물 조합 안 하위(구조물 도면 · 일위대가 · 면적산출 · 각 「전체 항목」) 없앰 ·
|
||||
상세는 항목 카드 [수정]으로."""
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
kinds = side.split("const COLUMN_KINDS")[1].split("];")[0]
|
||||
order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")]
|
||||
assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출
|
||||
assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds
|
||||
# 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길
|
||||
assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side
|
||||
for gone in (
|
||||
"COLUMN_KINDS",
|
||||
"columnSections",
|
||||
"onAll",
|
||||
"allPicked",
|
||||
"selectColumn",
|
||||
"m02-side__rule",
|
||||
):
|
||||
assert gone not in side, gone
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert "buildAllItems" not in page and "onAll" not in page and "ALL_TITLE" not in page
|
||||
assert "buildAllItems" not in _text("M02_MasterTemplete_ItemList.ts")
|
||||
api = _text("M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert '| "labor";' in api and "일위대가번: string;" in api
|
||||
assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음
|
||||
assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side
|
||||
assert 'selectAll("labor")' not in side # 해시 open=일위대가 길 없앰(44-7)
|
||||
locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8")
|
||||
assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale
|
||||
for gone in ("M02_ItemsAll", "M02_KindLabor", "M02_KindStructure", "M02_KindArea"):
|
||||
assert gone not in locale, gone
|
||||
assert "B01_Dashboard_CardUnitCombo" not in locale # 대시보드 일위대가 카드 없앰(44-7)
|
||||
css = _text("M02_MasterTemplete_UI_Style.css")
|
||||
assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css
|
||||
assert ".m02-side__rule" not in css and ".m02-side__summary" not in css
|
||||
|
||||
|
||||
def test_층_단추는_구조물_조합_한_벌():
|
||||
@@ -55,11 +61,11 @@ def test_층_단추는_구조물_조합_한_벌():
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
|
||||
def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음():
|
||||
def test_나무가_바뀌면_집계표를_다시_엶():
|
||||
"""39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` →
|
||||
`onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴."""
|
||||
`onChanged` 한 길 · 좌측 하위 열 줄은 없앰(45-2)."""
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
changed = page.split("onChanged: async (colId) => {")[1].split("},")[0]
|
||||
assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed
|
||||
assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" not in changed
|
||||
panel = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침
|
||||
|
||||
@@ -125,15 +125,16 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
assert (
|
||||
order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice")
|
||||
)
|
||||
# 실무문서를 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 실무문서 · … 지움(자재 조합 없앰)
|
||||
assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side
|
||||
assert "onOpen: () => (practice?.clear(), (allPicked = null), paint())" in side
|
||||
# 실무문서를 열면 이 줄 고름 지움 / 이 줄을 열면 실무문서 고름 지움(자재 조합 · 「전체 항목」 없앰)
|
||||
assert "onOpen: () => (options.clear(), paint())" in side
|
||||
assert "onOpen: () => (practice?.clear(), paint())" in side
|
||||
assert "matset" not in side
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
|
||||
assert (
|
||||
'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api
|
||||
'call("/layers/system/structure-options")' in api
|
||||
and 'call("/structure-options", { method: "PUT"' in api
|
||||
)
|
||||
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8")
|
||||
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
||||
|
||||
@@ -51,10 +51,6 @@ def test_소스가_700줄을_넘지_않음():
|
||||
),
|
||||
# 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함
|
||||
("M02_MasterTemplete_Api_Fetch.ts", ["saveTemplate"], MAIN),
|
||||
# 좌측 구조물 도면 · 면적산출 줄 번호는 한 번에 받음
|
||||
# (`{열 id: {도번, 면적번}}`)
|
||||
("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE),
|
||||
("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE),
|
||||
# 도면 카드(31-4)는 카드 파일에서 뗀 `DrawingCard` 가 부름
|
||||
("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], DRAWING_CARD),
|
||||
("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], CARDS),
|
||||
@@ -101,21 +97,15 @@ def test_집계표만_컨테이너를_붙임():
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_열_머리_글_잇기():
|
||||
"""`columnHead` — 빈 층은 건너뛰고 빈칸으로 이음 · 다 비면 열 id ·
|
||||
`structureLabel` — 번호가 맨 앞(「SD010101 돌기슭막이 …」) · 번호 없으면 이름만."""
|
||||
funcs = [
|
||||
re.search(rf"^export const {name}[\s\S]*?;$", _text(PANEL), re.M)
|
||||
for name in ("columnHead", "structureLabel")
|
||||
]
|
||||
assert all(funcs), "columnHead · structureLabel 을 못 찾음"
|
||||
"""`columnHead` — 빈 층은 건너뛰고 빈칸으로 이음 · 다 비면 열 id."""
|
||||
funcs = [re.search(r"^export const columnHead[\s\S]*?;$", _text(PANEL), re.M)]
|
||||
assert all(funcs), "columnHead 를 못 찾음"
|
||||
# 부품 파일은 화면 모듈을 부르므로 통째로 못 싣음 — 그 함수만 떼어 Node 가 타입을 벗겨 돌림
|
||||
script = (
|
||||
"interface StructureColumn { id: string; 머리: (string | null)[] }\n"
|
||||
+ "\n".join(f.group(0).replace("export ", "") for f in funcs)
|
||||
+ "\nconsole.log(JSON.stringify(process.argv.slice(1)"
|
||||
+ ".map((a) => columnHead(JSON.parse(a)))"
|
||||
+ ".concat([structureLabel('SD010101', {id: 'rv', 머리: ['돌기슭막이', null, 'H=1.0']}),"
|
||||
+ " structureLabel('', {id: 'rv', 머리: ['돌기슭막이', null, null]})])));"
|
||||
+ ".map((a) => columnHead(JSON.parse(a)))));"
|
||||
)
|
||||
cases = [
|
||||
{"id": "pv_l", "머리": ["콘크리트포장", "T=(두께별)", "L"]},
|
||||
@@ -133,8 +123,6 @@ def test_열_머리_글_잇기():
|
||||
"콘크리트포장 T=(두께별) L",
|
||||
"NO",
|
||||
"etc",
|
||||
"SD010101 돌기슭막이 H=1.0",
|
||||
"돌기슭막이",
|
||||
]
|
||||
|
||||
|
||||
@@ -159,16 +147,10 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
|
||||
assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만
|
||||
assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만
|
||||
assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만
|
||||
# 구조물 도면 · 일위대가 · 면적산출 = 구조물 집계 양식 컨테이너 안(같이 접힘) · 자재 조합 없앰 ·
|
||||
# 층 단추 · 나무와 하위 사이 수평선(39-2)
|
||||
# 구조물 조합 컨테이너 = 층 단추 · 나무만(하위 구조물 도면 · 일위대가 · 면적산출 없앰 · 45-2)
|
||||
summary = side.split("const summary = section(")[1].split(");")[0]
|
||||
assert (
|
||||
summary.index("opt.tree")
|
||||
< summary.index("m02-side__rule")
|
||||
< summary.index("...columnSections")
|
||||
)
|
||||
assert "opt.tree" in summary and "columnSections" not in summary and '"hr"' not in summary
|
||||
assert "matset" not in side
|
||||
assert "const columnSections = COLUMN_KINDS.map(" in side
|
||||
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
|
||||
ROOT / "ui_template" / "ui_template_locale_m2.ts"
|
||||
)
|
||||
@@ -197,17 +179,12 @@ def test_첫_화면과_닫은_뒤는_구조물집계표():
|
||||
|
||||
|
||||
def test_좌측_고름_표시는_열린_것_한_줄뿐():
|
||||
"""카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림."""
|
||||
"""카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 옵션 줄을 함께 그림."""
|
||||
side = _text(SIDE)
|
||||
assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄
|
||||
assert re.search(
|
||||
r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*"
|
||||
r"paintList\(\);\s*paintColumns\(\);",
|
||||
side,
|
||||
) # 양식이 열리면 「전체 항목」 고름도 지움(39-2)
|
||||
for fn in ("async function select(", "async function selectColumn("):
|
||||
body = side.split(fn)[1].split("\n }\n")[0]
|
||||
assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음
|
||||
assert re.search(r"function paint\(\): void \{\s*paintList\(\);", side)
|
||||
body = side.split("async function select(")[1].split("\n }\n")[0]
|
||||
assert "paint" not in body # 여기서 한쪽만 그리면 두 줄이 남음
|
||||
assert re.search(r"sel = next;\s*onOpened\?\.\(\);", _text(MAIN))
|
||||
# 옵션 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 옵션 고름 지움
|
||||
assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN))
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기
|
||||
/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 · 판 크기 바뀜에도 배율 1
|
||||
* 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘
|
||||
* · 창 너비 1500px 이상(그 아래면 오른쪽 칸이 접힘)
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음
|
||||
* 기대 수치:
|
||||
* - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반)
|
||||
* 하는 일: [계획노선 편집] 열기 → 오른쪽 칸 크기를 세 갈래로 둔 채 측점 라벨 누름 → 칸을 원래대로 → 잼
|
||||
* folded = 칸 접힘(display none) 상태로 셈 → 펼침 · narrow = 260×300 으로 셈 → 원래
|
||||
* wide = 원래 크기로 셈 · shrink = wide 뒤 260×300 으로 줄임 → [취소]로 닫음
|
||||
* · 탭이 화면에 안 보여 프레임이 없으면(frames = false) ResizeObserver 대역(100ms 재기)을 깔고 돎
|
||||
* 기대 수치(갈래마다):
|
||||
* - sx · sy = 화면 크기 ÷ viewBox = 1(±0.02 · 글자가 늘어나 보이지 않음)
|
||||
* - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 11px · screenPx(11 × sy) 10~12
|
||||
* - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기)
|
||||
* - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침)
|
||||
* - 바꾸는 값 없음(칸 크기 임시 style 지움 · 취소로 닫음 · 노선 안 고침)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -21,13 +26,50 @@
|
||||
[...document.querySelectorAll("button")].find(
|
||||
(b) => b.offsetParent && b.textContent.trim() === text,
|
||||
);
|
||||
// 화면에 안 보이는 ORCA 탭은 프레임을 안 그려 ResizeObserver 가 안 옴 — 그때만 100ms 마다
|
||||
// 크기를 재어 같은 방식(처음 한 번 · 바뀔 때마다)으로 부르는 대역을 편집 창 열기 전에 깖
|
||||
const OrigRO = window.ResizeObserver;
|
||||
const frames = await Promise.race([
|
||||
new Promise((r) => requestAnimationFrame(() => r(true))),
|
||||
w(500).then(() => false),
|
||||
]);
|
||||
if (!frames)
|
||||
window.ResizeObserver = class {
|
||||
constructor(cb) {
|
||||
this.cb = cb;
|
||||
this.t = new Map();
|
||||
this.iv = setInterval(() => {
|
||||
for (const [el, last] of this.t) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const k = `${r.width}x${r.height}`;
|
||||
if (k === last) continue;
|
||||
this.t.set(el, k);
|
||||
this.cb([{ target: el }], this);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
observe(el) {
|
||||
if (!this.t.has(el)) this.t.set(el, null);
|
||||
}
|
||||
unobserve(el) {
|
||||
this.t.delete(el);
|
||||
}
|
||||
disconnect() {
|
||||
this.t.clear();
|
||||
clearInterval(this.iv);
|
||||
}
|
||||
};
|
||||
btn("계획노선 편집")?.click();
|
||||
const canvas = await until(() =>
|
||||
[...document.querySelectorAll("canvas")].find(
|
||||
(c) => c.offsetParent && c.closest("[class*='routeedit']"),
|
||||
),
|
||||
);
|
||||
window.ResizeObserver = OrigRO; // 편집 창(횡단 판)은 다 지어짐
|
||||
if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" });
|
||||
const side = document.querySelector(".b05-routeedit__side");
|
||||
if (!side || !side.offsetParent)
|
||||
return JSON.stringify({ ok: false, why: "오른쪽 칸 접힘", innerWidth });
|
||||
// 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀
|
||||
const url = performance
|
||||
.getEntriesByType("resource")
|
||||
@@ -39,34 +81,75 @@
|
||||
return b.length ? b : null;
|
||||
});
|
||||
if (!boxes) return JSON.stringify({ ok: false, why: "측점 라벨 상자 없음" });
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const box = boxes[Math.floor(boxes.length / 2)];
|
||||
const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true };
|
||||
canvas.dispatchEvent(new PointerEvent("pointerdown", at));
|
||||
canvas.dispatchEvent(new PointerEvent("pointerup", at));
|
||||
const pane = await until(() =>
|
||||
[...document.querySelectorAll(".b05-routeedit__cross")].find((p) =>
|
||||
p.querySelector(".b05-routeedit__cross-key"),
|
||||
),
|
||||
);
|
||||
if (!pane) return JSON.stringify({ ok: false, why: "횡단 판 안 뜸", chainage: box.chainage });
|
||||
const fs = (el) => el && getComputedStyle(el).fontSize;
|
||||
const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({
|
||||
text: t.textContent.slice(0, 20),
|
||||
fontSize: fs(t),
|
||||
h: +t.getBoundingClientRect().height.toFixed(1),
|
||||
}));
|
||||
const title = pane.querySelector(".b05-routeedit__cross-title");
|
||||
const station = pane.querySelector(".b05-routeedit__cross-station");
|
||||
const out = {
|
||||
keys,
|
||||
title: { text: title.textContent, fontSize: fs(title) },
|
||||
station: { text: station.textContent, fontSize: fs(station) },
|
||||
const pane = side.querySelector(".b05-routeedit__cross");
|
||||
const draw = async () => {
|
||||
const old = pane.querySelector("svg");
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const box = boxes[Math.floor(boxes.length / 2)];
|
||||
const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true };
|
||||
canvas.dispatchEvent(new PointerEvent("pointerdown", at));
|
||||
canvas.dispatchEvent(new PointerEvent("pointerup", at));
|
||||
return until(() => {
|
||||
const svg = pane.querySelector("svg");
|
||||
return svg && svg !== old && svg.querySelector(".b05-routeedit__cross-key");
|
||||
});
|
||||
};
|
||||
out.ok =
|
||||
keys.length === 3 &&
|
||||
keys.every((k) => k.fontSize === "5.5px") &&
|
||||
out.title.fontSize === out.station.fontSize;
|
||||
btn("취소")?.click();
|
||||
return JSON.stringify(out);
|
||||
const fs = (el) => el && getComputedStyle(el).fontSize;
|
||||
const measure = (tag) => {
|
||||
const svg = pane.querySelector("svg");
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const r = svg.getBoundingClientRect();
|
||||
const sx = +(r.width / vb.width).toFixed(3);
|
||||
const sy = +(r.height / vb.height).toFixed(3);
|
||||
const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({
|
||||
text: t.textContent.slice(0, 12),
|
||||
fontSize: fs(t),
|
||||
screenPx: +(parseFloat(fs(t)) * sy).toFixed(1),
|
||||
h: +t.getBoundingClientRect().height.toFixed(1),
|
||||
}));
|
||||
const title = pane.querySelector(".b05-routeedit__cross-title");
|
||||
const station = pane.querySelector(".b05-routeedit__cross-station");
|
||||
const res = {
|
||||
tag,
|
||||
viewBox: [vb.width, vb.height],
|
||||
box: [Math.round(r.width), Math.round(r.height)],
|
||||
sx,
|
||||
sy,
|
||||
keys,
|
||||
title: fs(title),
|
||||
station: fs(station),
|
||||
};
|
||||
res.ok =
|
||||
Math.abs(sx - 1) <= 0.02 &&
|
||||
Math.abs(sy - 1) <= 0.02 &&
|
||||
keys.length === 3 &&
|
||||
keys.every((k) => k.fontSize === "11px" && k.screenPx >= 10 && k.screenPx <= 12) &&
|
||||
res.title === res.station;
|
||||
return res;
|
||||
};
|
||||
const narrow = () => Object.assign(side.style, { width: "260px", height: "300px" });
|
||||
const reset = () => Object.assign(side.style, { display: "", width: "", height: "" });
|
||||
const out = [];
|
||||
try {
|
||||
side.style.display = "none";
|
||||
if (!(await draw())) return JSON.stringify({ ok: false, why: "접힘 · 횡단 안 뜸" });
|
||||
reset();
|
||||
await w(600);
|
||||
out.push(measure("folded"));
|
||||
narrow();
|
||||
if (!(await draw())) return JSON.stringify({ ok: false, why: "좁음 · 횡단 안 뜸" });
|
||||
reset();
|
||||
await w(600);
|
||||
out.push(measure("narrow"));
|
||||
if (!(await draw())) return JSON.stringify({ ok: false, why: "넓음 · 횡단 안 뜸" });
|
||||
await w(300);
|
||||
out.push(measure("wide"));
|
||||
narrow();
|
||||
await w(600);
|
||||
out.push(measure("shrink"));
|
||||
} finally {
|
||||
reset();
|
||||
btn("취소")?.click();
|
||||
}
|
||||
return JSON.stringify({ ok: out.every((o) => o.ok), innerWidth, frames, out });
|
||||
})();
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* 로직 GC000277(건설품셈 토목 1-1-2 유도선 설치 및 해체)을 엶 · 고치지 않음 · 끝에 패널 취향을 처음 값으로 되돌림.
|
||||
* 기대 수치:
|
||||
* order = ["기본정보","시험 계산","단가산출 상세","텍스트 수식"](흐름 로직이면 셋째 · 넷째는 흐름 보기 안)
|
||||
* flow = {flow:true, source:false, pref:"flow"} · 오른쪽 칸 너비 > 300
|
||||
* source = {flow:false, source:true, pref:"source"} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」
|
||||
* none = {flow:false, source:false, pref:"none"} · 오른쪽 칸 너비 = 80(띠 둘)
|
||||
* back = {flow:true, source:false, pref:"flow"}(흐름도 띠 단추로 다시 폄)
|
||||
* 두 패널 = 공용 접기 카드(ui-collapsible · 제목 행 누름) · 흐름도 패널 자체 접기 단추는 안 보임(dfpToggle 0)
|
||||
* flow = {flow:true, source:false, pref:"flow", caret:["▾","▸"]} · 오른쪽 칸 너비 > 300
|
||||
* source = {flow:false, source:true, pref:"source", caret:["▸","▾"]} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」
|
||||
* none = {flow:false, source:false, pref:"none", caret:["▸","▸"]} · 오른쪽 칸 너비 = 140(제목 행 둘)
|
||||
* back = {flow:true, source:false, pref:"flow"}(흐름도 제목 행으로 다시 폄)
|
||||
* noLink = 「원문 연결 없음」 + 「출처 · …」(서버가 빈 목록을 줄 때 · fetch 를 잠시 바꿔 봄)
|
||||
* restored = 처음 취향 값
|
||||
*/
|
||||
@@ -43,17 +44,23 @@
|
||||
out.order = $$(".m01lab-flow__left .m01lab__box > .m01-logic__section-head h3").map((h) =>
|
||||
h.innerText.trim(),
|
||||
);
|
||||
const slots = () => $$(".m01lab-side__slot");
|
||||
const cards = () => $$(".m01lab-side__card");
|
||||
const heads = () => cards().map((c) => $(".ui-collapsible__title", c));
|
||||
const shown = (n) => !!n && n.getBoundingClientRect().height > 0;
|
||||
const state = () => ({
|
||||
flow:
|
||||
slots()[0].classList.contains("is-open") &&
|
||||
!$(".m02-dfp", slots()[0]).classList.contains("is-folded"),
|
||||
source: slots()[1].classList.contains("is-open") && !$(".m02-dfp__open", slots()[1]).hidden,
|
||||
!cards()[0].classList.contains("is-collapsed") &&
|
||||
shown($(".m02-dfp__open", cards()[0])) &&
|
||||
!$(".m02-dfp", cards()[0]).classList.contains("is-folded"),
|
||||
source: !cards()[1].classList.contains("is-collapsed") && shown($(".m01lab-src", cards()[1])),
|
||||
pref: localStorage.getItem(PREF),
|
||||
caret: heads().map((h) => getComputedStyle(h, "::after").content.replace(/"/g, "")),
|
||||
width: Math.round($(".m01lab-flow__right").getBoundingClientRect().width),
|
||||
});
|
||||
const flowToggle = () => $(".m02-dfp__toggle", slots()[0]);
|
||||
const srcToggle = () => $(".m02-dfp__toggle", slots()[1]);
|
||||
const flowToggle = () => heads()[0];
|
||||
const srcToggle = () => heads()[1];
|
||||
out.titles = heads().map((h) => h.innerText.trim());
|
||||
out.dfpToggle = $$(".m01lab-side .m02-dfp__toggle").filter(shown).length;
|
||||
// 처음 = 흐름도 열림으로 맞춤
|
||||
if (!state().flow) {
|
||||
if (state().source) srcToggle().click();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* 45-2 — M02 좌측 「구조물 조합」 안 하위 컨테이너 셋(구조물 도면 · 일위대가 · 면적산출 · 「전체 항목」) 없앰 ·
|
||||
* 상세 길 = 나무 항목 → 카드 셋 [수정] → 상세 → [돌아가기]
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
|
||||
* 나무에 item1 이 있음
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안)
|
||||
* 기대 수치:
|
||||
* - side.nested = 0 · side.all = 0 · side.rule = 0(구조물 조합 안 하위 카드 · 「전체 항목」 · 수평선 없음)
|
||||
* - side.titles = ["구조물 조합", 옵션 줄, "도면 양식", "실무문서"] · side.gap > 0(카드 사이 틈 = 구분)
|
||||
* - cards = 3 · edits = 3(카드마다 [수정])
|
||||
* - detail 셋 모두 title 있음 · back = true · 돌아가기 뒤 cardsAfter = 3
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 5000) => {
|
||||
for (let t = 0; t < ms; t += 100) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(100);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
|
||||
const lists = document.querySelector(".m02-side__lists");
|
||||
const tops = [...lists.children].filter((c) => c.matches("section"));
|
||||
const summary = tops[0];
|
||||
const box = (e) => e.getBoundingClientRect();
|
||||
const out = {
|
||||
side: {
|
||||
titles: tops.map((s) => s.querySelector("h3")?.textContent.trim()),
|
||||
nested: summary.querySelectorAll(".ui-sidebar-card").length,
|
||||
all: document.querySelectorAll("[data-all], .m02-side__all").length,
|
||||
rule: summary.querySelectorAll("hr").length,
|
||||
gap: tops[1] ? Math.round(box(tops[1]).top - box(summary).bottom) : null,
|
||||
},
|
||||
};
|
||||
const edits = () =>
|
||||
[...document.querySelectorAll(".m02-struct button")].filter(
|
||||
(b) => vis(b) && b.textContent.trim() === "수정",
|
||||
);
|
||||
document.querySelector('[data-node="i|item1"]').click();
|
||||
await until(() => edits().length === 3);
|
||||
out.cards = document.querySelectorAll(".m02-struct .ui-sidebar-card, .m02-struct > *").length;
|
||||
out.edits = edits().map((b) => b.title);
|
||||
out.detail = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const b = await until(() => edits()[i]);
|
||||
if (!b) break;
|
||||
b.click();
|
||||
const back = await until(() =>
|
||||
[...document.querySelectorAll(".m02-main__head button")].find(
|
||||
(x) => vis(x) && x.textContent.trim() === "돌아가기",
|
||||
),
|
||||
);
|
||||
const title = document.querySelector(".m02-main__title")?.textContent.trim() ?? null;
|
||||
back?.click();
|
||||
const again = await until(() => edits().length === 3);
|
||||
out.detail.push({ title, back: !!back, cardsAfter: again ? 3 : edits().length });
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
/* 45-3 — 구조물 조합 상세 분류 표: 일위대가 · 도면 · 면적산출 키 줄 안 보임 · [저장] 뒤 장부 번호 그대로
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글 · 20초 안)
|
||||
* 쓰는 것: 표 [저장] 한 번(고침 없음) — 끝나면 resources/master_template 사본으로 되씀
|
||||
* 기대 수치:
|
||||
* - metaRows = 0 · refRowNames = 0(줄 머리 「일위대가 · 도면 · 면적산출」) · refCells = 0(data-r s:price|drawing|area)
|
||||
* - headPick = true(머리 칸 누르면 열 고름 · 카드 셋) · keyMove = true(방향키로 옆 열)
|
||||
* - book.same = true(저장 앞뒤 장부 줄의 키 · 열 · 도번 · 면적번 같음) · book.laborAfter = rows(일위대가번 모두 있음)
|
||||
* (git 장부가 옛 꼴(재료번)이면 저장 때 서버 맞춤이 일위대가번으로 바꿈 — 보기와 무관)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 6000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
|
||||
const sheet = await until(() => document.querySelector(".ui-sheet--combo"));
|
||||
if (!sheet) return JSON.stringify({ error: "구조물 조합 표 없음" });
|
||||
const out = {};
|
||||
const gutters = () =>
|
||||
[...sheet.querySelectorAll(".ui-sheet__gutter")].map((g) => g.textContent.trim());
|
||||
out.metaRows = sheet.querySelectorAll(".ui-sheet__meta").length;
|
||||
out.refRowNames = gutters().filter((t) => ["일위대가", "도면", "면적산출"].includes(t)).length;
|
||||
out.refCells = sheet.querySelectorAll(
|
||||
'[data-r="s:price"],[data-r="s:drawing"],[data-r="s:area"]',
|
||||
).length;
|
||||
out.gutters = gutters();
|
||||
|
||||
// 머리 칸 누르면 열 고름(카드) · 방향키 = 옆 열
|
||||
const heads = [...sheet.querySelectorAll("thead th[data-col]")].filter(vis);
|
||||
const deepest = Math.max(...heads.map((h) => Number(h.dataset.level)));
|
||||
const leaves = heads.filter((h) => Number(h.dataset.level) === deepest);
|
||||
leaves[0].click();
|
||||
await w(400);
|
||||
out.headPick = !!sheet.querySelector("th.is-head-picked");
|
||||
const first = sheet.querySelector("th.is-head-picked")?.dataset.col;
|
||||
sheet.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
await w(300);
|
||||
const second = sheet.querySelector("th.is-head-picked")?.dataset.col;
|
||||
out.keyMove = !!first && !!second && first !== second;
|
||||
|
||||
// [저장] 앞뒤 장부(구조물집계표조합) 번호
|
||||
const book = () =>
|
||||
fetch("/api/m02/layers/system/structure-combos", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((b) =>
|
||||
(b.줄 ?? []).map((r) => ({
|
||||
id: JSON.stringify([r.키, r.열, r.도번, r.면적번]),
|
||||
su: r.일위대가번,
|
||||
})),
|
||||
);
|
||||
const before = await book();
|
||||
const save = [...document.querySelectorAll("button")].find(
|
||||
(b) => vis(b) && b.innerText.trim() === "저장",
|
||||
);
|
||||
save.click();
|
||||
await w(2500);
|
||||
const after = await book();
|
||||
out.book = {
|
||||
rows: before.length,
|
||||
same: JSON.stringify(before.map((r) => r.id)) === JSON.stringify(after.map((r) => r.id)),
|
||||
laborAfter: after.filter((r) => r.su).length,
|
||||
sample: after[0],
|
||||
};
|
||||
out.metaAfterSave = document.querySelectorAll(".ui-sheet--combo .ui-sheet__meta").length;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -141,10 +141,6 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.ui-sheet--combo .ui-sheet__meta td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-sheet__table tr.is-fixed td {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -222,7 +222,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
opts.onSelect?.(selectionOf(state.doc, r, c));
|
||||
};
|
||||
const move = (dr: number, dc: number): void => {
|
||||
const keys = navRows(state.doc, state.mode).filter((k) => !opts.combo || k !== KEY_UNIT);
|
||||
// 구조물 조합 = 머리만(단위 · 번호 줄 없음) — 방향키는 열만 옮김(머리 = 단위 줄 자리)
|
||||
const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode);
|
||||
const cols = state.doc.열;
|
||||
if (!state.sel) {
|
||||
if (keys.length && cols.length) select(keys[0], cols[0].id, true);
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface RenderState {
|
||||
pick?: boolean;
|
||||
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
|
||||
headLocked?: boolean;
|
||||
/** 구조물 조합 표(`combo`) — 단위 줄 없음 · 열 폭 = 머리 · 번호 글이 한 줄에 들게(저장 폭 안 씀) ·
|
||||
/** 구조물 조합 표(`combo`) — 단위 줄 · 일위대가 · 도면 · 면적산출 줄 없음 · 열 폭 = 머리(저장 폭 안 씀) ·
|
||||
* `headEditable` = 머리 이름 칸을 고칠 수 있음(나무 이름 바꾸기로). */
|
||||
combo?: boolean;
|
||||
headEditable?: boolean;
|
||||
@@ -68,18 +68,6 @@ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>)
|
||||
mins.get(col.id) ?? 0,
|
||||
);
|
||||
|
||||
/** 구조물 조합 — 일위대가 · 도면 · 면적산출 줄 글(키 · 번호)이 줄바꿈 없이 들게 최소 폭을 넓힘
|
||||
* (`.ui-sheet__meta td` 글꼴과 같게 잼). */
|
||||
function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, number>): void {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const font = `400 ${rem * 0.76}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
for (const col of cols) {
|
||||
const texts = REF_ROWS.map((key) => refText(state, key, col.id));
|
||||
const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14;
|
||||
mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need));
|
||||
}
|
||||
}
|
||||
|
||||
/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */
|
||||
function headFont(): string {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
@@ -113,7 +101,6 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const names = columnNames(cols);
|
||||
const font = headFont();
|
||||
const mins = headMinWidths(cols, depth, font);
|
||||
if (state.combo) fitMeta(state, cols, mins);
|
||||
const widthDoc = state.combo ? { ...doc, 보기: { ...doc.보기, 열너비: {} } } : doc;
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 일위대가 · 도면 · 면적산출 · 합계 이름이 안 잘리게
|
||||
const GUTTER = Math.min(
|
||||
@@ -275,7 +262,8 @@ ${formulaLabel(formula, names)}`
|
||||
for (const [p, page] of plan.entries()) {
|
||||
const body = el("tbody", {
|
||||
children: [
|
||||
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
|
||||
// 구조물 조합 = 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만 · 번호는 장부 · 카드에)
|
||||
...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : []),
|
||||
...page.rows.map((row, i) => dataRow(row, page.first + i)),
|
||||
...(page.totals ? totalRows() : []),
|
||||
],
|
||||
|
||||
@@ -10,8 +10,6 @@ export const ui_locales_m2 = {
|
||||
M02_Title: ["마스터 템플릿", "Master Templates"],
|
||||
M02_KindTable: ["구조물 조합", "Structure combos"],
|
||||
M02_KindDrawing: ["도면 양식", "Drawing templates"],
|
||||
M02_KindStructure: ["구조물 도면", "Structure drawings"],
|
||||
M02_KindArea: ["면적산출", "Area takeoff"],
|
||||
M02_NoTemplates: ["양식 없음", "None"],
|
||||
M02_New: ["새로", "New"],
|
||||
M02_Copy: ["본떠 만들기", "Copy as new"],
|
||||
@@ -56,7 +54,6 @@ export const ui_locales_m2 = {
|
||||
"Unsaved changes — discard and continue?",
|
||||
],
|
||||
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
|
||||
M02_StructureNone: ["없음", "None"],
|
||||
M02_Back: ["돌아가기", "Back"],
|
||||
M02_TitleCompany: ["구조물 관리(회사)", "Structure management (company)"],
|
||||
M02_TitlePersonal: ["구조물 관리(개인)", "Structure management (personal)"],
|
||||
@@ -165,8 +162,6 @@ export const ui_locales_m2 = {
|
||||
M02_TreeSaveGo: ["저장하고 계속", "Save and continue"],
|
||||
M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"],
|
||||
M02_TreeDone: ["나무를 고침", "Tree updated"],
|
||||
M02_KindLabor: ["일위대가", "Unit costs"],
|
||||
M02_ItemsAll: ["전체 항목", "All items"],
|
||||
M02_PracticeTitle: ["실무문서", "Practice documents"],
|
||||
M02_PracticeRow: ["실무문서 목록", "Practice document list"],
|
||||
M02_PracticeCount: [
|
||||
|
||||
Reference in New Issue
Block a user