Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-05 11:31:55 +09:00
31 changed files with 823 additions and 80 deletions
+12 -3
View File
@@ -112,6 +112,13 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
const dockParts: HTMLElement[] = [];
const dock = createDesignDock(view, dockParts, ctx.dock);
// 칸은 공용 스크롤 칸(쪼갠 뒤) 안에 둔다 — 쪼개기 전이면 패널 루트의 단추 묶음 앞.
const place = (element: HTMLElement): void => {
const scroll = root.querySelector<HTMLElement>(":scope > .ui-sidebar-scroll");
if (scroll) scroll.append(element);
else root.insertBefore(element, dock.root);
};
const show = (target: DesignView): void => {
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
for (const def of panelSectionsFor(target)) {
@@ -120,12 +127,12 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
const sectionRoot = "root" in made ? made.root : made;
if ("root" in made && made.dock) dock.root.prepend(made.dock);
built.set(def.id, sectionRoot);
root.insertBefore(sectionRoot, dock.root);
place(sectionRoot);
}
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
for (const def of panelSectionsFor(target)) {
const element = built.get(def.id);
if (element) root.insertBefore(element, dock.root);
if (element) place(element);
}
built.forEach((element, id) => {
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
@@ -144,7 +151,9 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De
dock,
setView: (next) => {
dock.setView(next);
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
// 클래스 통째 덮기 금지 — 공용 쪼개기가 붙인 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다.
root.classList.toggle("b05-route__panel", next === "profile");
root.classList.toggle("b06-profile__form", next !== "profile");
show(next);
},
};
+4
View File
@@ -12,6 +12,7 @@
import { ROUTES } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { splitSidebarActions } from "@ui/ui_template_overlay";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
import { navigateTo } from "../router";
@@ -229,6 +230,9 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel {
shared.view(),
);
shared.left.append(panel.root);
// 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다.
const body = shared.left.closest<HTMLElement>(".ui-workflow-overlay__body");
if (body) splitSidebarActions(body);
shared.panel = panel;
return panel;
}
+1 -5
View File
@@ -178,7 +178,6 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
navigateTo(ROUTES.M01_MASTER_DATA),
),
m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
@@ -193,10 +192,7 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
wrap.append(
m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc),
);
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc));
return wrap;
}
@@ -4,7 +4,8 @@
*
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
* (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만
* 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차.
*
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
@@ -56,6 +57,8 @@ export interface MeasureTool {
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */
preview: (px: number, py: number) => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
@@ -109,6 +112,8 @@ export function bindMeasureControls(params: MeasureControlsParams): {
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
let hover: MeasureMark | null = null;
const hint = (): string => {
if (picked.length === 0) return "";
@@ -133,10 +138,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
);
};
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
/** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
const lines = params.lines();
const hits = (["planned", "expected"] as const)
const candidates: MeasureLine[] =
picked.length === 1 ? [picked[0].line] : ["planned", "expected"];
const hits = candidates
.map((line) => {
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) return null;
@@ -145,30 +152,42 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
})
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
if (!hits.length) return null;
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a));
return { point: best.point, chainageM: best.chainageM, line: best.line };
};
return {
marks: () =>
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
marks: () => [
...picked.map((entry) => ({
point: entry.point,
chainageM: entry.chainageM,
line: entry.line,
})),
...(picked.length === 1 && hover ? [hover] : []),
],
hint,
active: () => picked.length > 0,
preview(px, py) {
const next = picked.length === 1 ? hitAt(px, py) : null;
if (!next && !hover) return false;
hover = next;
return true;
},
clear() {
hover = null;
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) {
const hit = hitAt(px, py);
hover = null;
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
@@ -71,7 +71,11 @@ export interface RouteEditPointerParams {
/** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */
unrotate: (x: number, y: number) => [number, number];
contourStepM: () => number;
measure: { pick: (px: number, py: number) => unknown; clear: () => void };
measure: {
pick: (px: number, py: number) => unknown;
preview: (px: number, py: number) => boolean;
clear: () => void;
};
/** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */
measureMode: () => boolean;
crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown };
@@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
params.draw();
return;
}
// 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다.
if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw();
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
@@ -187,7 +187,7 @@
.b05-routeedit__cross-station {
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-size: inherit; /* 44-2 — 제목(횡단)과 같은 크기 */
}
/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
@@ -235,7 +235,7 @@
.b05-routeedit__cross-key {
fill: var(--color-text-secondary, #94a3b8);
font-size: 11px;
font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */
}
.b05-routeedit__cross-key.is-design {
+9 -2
View File
@@ -1,6 +1,6 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -26,6 +26,7 @@ mm = mf.mp.mm
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
def _bad(text: str) -> store.StoreError:
@@ -83,10 +84,16 @@ def clean(doc: Any) -> dict[str, Any]:
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo = one.get("메모")
memo, alias = one.get("메모"), one.get("별칭")
if alias is not None and not isinstance(alias, str):
raise _bad(f"{where} · 별칭은 글")
alias = (alias or "").strip()
if len(alias) > ALIAS_MAX:
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
lines.append(
{
"로직": code,
"별칭": alias or None,
"메모": str(memo) if memo else None,
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
@@ -78,6 +78,64 @@
white-space: nowrap;
}
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
.m02-labrow .m02-labrow__optrow td {
padding-top: 0;
padding-bottom: var(--spacing-4);
font-size: var(--text-caption);
line-height: 1.5;
}
.m02-labrow__opt {
max-width: 0;
overflow: hidden;
color: var(--color-text-secondary);
white-space: nowrap;
text-overflow: ellipsis;
}
.m02-labrow__row:has(+ .m02-labrow__optrow) td {
border-bottom: 0;
}
.m02-labrow__optrow.is-open {
cursor: pointer;
}
.m02-labrow__optrow.is-open:hover,
.m02-labrow__row.is-open:hover + .m02-labrow__optrow {
background: var(--color-mist-violet);
}
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
.m02-labrow__alias {
min-width: 7rem;
}
.m02-labrow__aliasin {
width: 100%;
min-width: 6rem;
padding: 2px var(--spacing-4);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm, 4px);
background: transparent;
color: inherit;
font: inherit;
}
.m02-labrow__aliasin:hover,
.m02-labrow__aliasin:focus {
border-color: var(--color-primary);
border-style: solid;
}
/* 총합계 줄 전체 합계 칸(44-6) — 테두리 · 색 */
.m02-labrow__sum .m02-labrow__total {
border: 2px solid var(--color-primary);
background: var(--color-mist-violet);
color: var(--color-primary);
}
/* 입력 모달 */
.m02-labin {
width: min(40rem, calc(100vw - 32px));
@@ -234,6 +292,17 @@
order: 3;
text-align: right;
}
/* 옵션 값 줄 — 한 줄 통째 */
.m02-labrow__optrow .m02-labrow__no,
.m02-labrow__sum .m02-labrow__alias {
display: none;
}
.m02-labrow .m02-labrow__opt {
grid-column: 1 / -1;
max-width: none;
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
@@ -121,6 +121,8 @@ export type LaborQty = { 손: number } | null;
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
export interface LaborRow {
로직: string;
/** 사용자가 지은 이름(44-6 · 빈 것 = null) */
별칭?: string | null;
메모: string | null;
입력값: Record<string, string | number>;
수량: LaborQty;
@@ -153,6 +155,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
단위: d.단위 ?? null,
담은로직: (d.담은로직 ?? []).map((r) => ({
로직: r.로직,
별칭: r.별칭 ?? null,
메모: r.메모 ?? null,
입력값: r.입력값 ?? {},
수량: r.수량 ?? null,
@@ -43,6 +43,7 @@ import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
@@ -135,7 +136,13 @@ export function mountLaborDetail(
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit
? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow }
? {
open: openRow,
qty: openQty,
...(opt.card ? {} : { move }),
remove: removeRow,
alias: setAlias,
}
: { open: openRow },
),
);
@@ -262,6 +269,11 @@ export function mountLaborDetail(
cur.담은로직.splice(i, 1);
changed();
}
/** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */
function setAlias(i: number, value: string | null): void {
cur.담은로직[i].별칭 = value;
opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
@@ -311,7 +323,7 @@ export function mountLaborDetail(
openLogicPicker((x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
void loadLogics().then(() => changed());
}),
});
@@ -330,15 +342,27 @@ export function mountLaborDetail(
}),
});
const unit = createInputField({ type: "text", label: tc("Labor_Unit") });
unit.input.value = cur.단위 ?? "";
unit.input.readOnly = !edit;
unit.input.maxLength = 20;
unit.input.setAttribute("data-labor-field", "단위");
unit.input.addEventListener("input", () => {
cur.단위 = unit.input.value.trim() || null;
opt.onChanged(dirty());
});
// 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장]
const unit = {
root: el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }),
};
const unit0 = cur.단위;
const drawUnit = (): void =>
unit.root.replaceChildren(
el("label", { className: "ui-field__label", text: tc("Labor_Unit") }),
createUnitPick({
value: cur.단위 || NO_UNIT,
extra: [unit0, cur.단위].filter((u): u is string => !!u),
readOnly: !edit,
ariaLabel: tc("Labor_Unit"),
onPick: (u) => {
cur.단위 = u === NO_UNIT ? null : u;
opt.onChanged(dirty());
drawUnit();
},
}),
);
drawUnit();
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.readOnly = true;
@@ -1,8 +1,9 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
* 금액은 calc 답(없으면 「…」).
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
@@ -31,11 +32,14 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
계: "Prev_Sum",
};
/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
const SPAN = 4 + COSTS.length;
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
* 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -44,12 +48,12 @@ export function qtyText(
if (!line) return "";
if (line.수량 === null || line.수량 === undefined) {
const at = line.알약 ? cells.get(line.알약) : undefined;
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
return at ? `${tc("Labor_Qty")} ${tc("In_QtyEmpty")}(${at})` : "";
}
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
return `${tc("Labor_Qty")} ${cell} = ${v}`;
return `${tc("Labor_Qty")} ${v}(${cell})`;
}
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
@@ -60,7 +64,7 @@ export function baseQtyText(
return `${num(line.배 || 1)} ${line.분모}`;
}
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */
export function qtyShort(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -68,13 +72,13 @@ export function qtyShort(
): string {
const at = line?.알약 ? cells.get(line.알약) : undefined;
if (!line || line.수량 === null || line.수량 === undefined)
return at ? `${at} = ${tc("In_QtyEmpty")}` : "—";
return at ? `${tc("In_QtyEmpty")}(${at})` : "—";
if (line.수량출처 === "손")
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`;
return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`;
}
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) ·
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
line: LaborCalcLine | undefined,
@@ -155,6 +159,42 @@ export interface RowHandlers {
qty?: (i: number) => void;
move?: (i: number, d: number) => void;
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
alias?: (i: number, value: string | null) => void;
}
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
function aliasCell(
value: string | null | undefined,
set: ((v: string | null) => void) | null,
): HTMLElement {
if (!set)
return el("td", {
className: "m02-labrow__alias",
text: value ?? "",
attrs: { "data-label": tc("Col_Alias") },
});
const input = el("input", {
className: "m02-labrow__aliasin",
attrs: {
type: "text",
maxlength: "40", // 서버 `ALIAS_MAX` 와 같음
title: tc("Alias_Edit"),
"aria-label": tc("Col_Alias"),
"data-labor-alias": "",
},
});
input.value = value ?? "";
input.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(입력 모달)과 따로
input.addEventListener("input", () => set(input.value.trim() || null));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
return el("td", {
className: "m02-labrow__alias",
attrs: { "data-label": tc("Col_Alias") },
children: [input],
});
}
export interface LogicTableData {
@@ -175,7 +215,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const tools = !!(move || remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
const trs = data.rows.flatMap((row, i) => {
const logic = data.logics.get(row.로직);
const line = lineOf(row.로직);
const unit = logic?.결과단위 ?? "";
@@ -201,7 +241,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("span", { className: "m02-labrow__key", text: row.로직 }),
el("strong", { text: logic?.이름 ?? row.로직 }),
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
...(values.empty
? [
el("small", {
@@ -221,6 +260,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
: []),
],
}),
aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null),
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", { className: "m02-labrow__unit", text: unit }),
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
@@ -240,7 +280,23 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
if (!note) return [tr];
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
const opt = el("tr", {
className: on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow",
attrs: { "data-labor-opt": row.로직 },
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", {
className: "m02-labrow__opt",
text: note,
attrs: { colspan: String(SPAN), title: note },
}),
...(tools ? [el("td", { text: "" })] : []),
],
});
if (on) opt.addEventListener("click", () => on.open(i));
return [tr, opt];
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
@@ -251,11 +307,19 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", { className: "m02-labrow__alias", text: "" }),
el("td", { className: "m02-labrow__qty", text: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...COSTS.map((c) =>
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])),
),
...COSTS.map((c) => {
const td = moneyCell(
sum(data.calc?.기준합, c),
sum(data.calc?.반영합, c),
wait,
tc(HEAD[c]),
);
if (c === "계") td.classList.add("m02-labrow__total"); // 전체 합계 강조(44-6)
return td;
}),
...(tools ? [el("td", { text: "" })] : []),
],
}),
@@ -264,6 +328,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const heads = [
tc("Col_Order"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
@@ -27,6 +27,8 @@ const TEXT = {
Logics_Title: ["단가산출 로직", "Unit-price logics"],
Col_Order: ["차례", "No."],
Col_Name: ["이름", "Name"],
Col_Alias: ["별칭", "Alias"],
Alias_Edit: ["눌러서 별칭 고침", "Click to edit alias"],
Col_Unit: ["결과 단위", "Result unit"],
Col_Move: ["순서", "Move"],
Up: ["올리기", "Up"],
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Layer.ts
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
* `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…&company=…`
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…`
* (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
* ========================================================================== */
@@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A
export type ManageLayer = "system" | "company" | "personal";
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
export type Root = "master" | "company" | "personal";
export type OpenTarget = "구조물조합" | "일위대가" | "옵션" | "도면";
export type OpenTarget = "구조물조합" | "옵션" | "도면";
export interface Entry {
root: Root;
@@ -27,7 +27,7 @@ export interface Entry {
company: string | null;
}
const OPENS: OpenTarget[] = ["구조물조합", "일위대가", "옵션", "도면"];
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"];
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
@@ -226,7 +226,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
root.innerHTML = "";
root.append(layout);
void main.open(home);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 구조물 조합 하위(세 층) ·
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
void tree.refresh();
@@ -94,7 +94,7 @@ export interface SideHandle {
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
reveal: (target: "옵션" | "도면" | "일위대가") => void;
reveal: (target: "옵션" | "도면") => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -414,14 +414,7 @@ export function buildSide(opt: SideOptions): SideHandle {
}
}
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
// 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」
if (target === "일위대가") {
columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove(
"is-collapsed",
);
return void selectAll("labor");
}
const reveal = (target: "옵션" | "도면"): void => {
extras?.[target].classList.remove("is-collapsed");
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
};
+3 -6
View File
@@ -70,31 +70,28 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_카드_다섯_고른_회사_해시():
def test_시스템_관리자_카드_넷_고른_회사_해시():
m = "B01_Dashboard_PermMasterWrite"
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master&company=3"],
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
]
# 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
f"{M02}?open=일위대가&root=master"
f"{M02}?open=구조물조합&root=master"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_회사_관리자_개인_카드_둘():
def test_회사_관리자_개인_카드_하나():
company = [
["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"],
["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"],
]
assert _cards({"role": "ADMIN", "is_master": False}, 3) == company # 회사 인자 안 실음
assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정
assert _cards({"role": "USER", "is_master": False}) == [
["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"],
["B01_Dashboard_CardStructCombo", READ_CO, f"{M02}?open=구조물조합&root=personal"],
]
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""B05 · B06 좌측 패널 아래 고정(계획서 44-3) — 단추 묶음이 패널 바닥에 붙고 위 칸만 스크롤.
공용 쪼개기(`splitSidebarActions`)가 [스크롤 칸][아래 고정 단추] 로 나누는데, 한 페이지(design_page)는
좌측 자리를 빈 채 틀에 넘겨 쪼개기가 지나가고, 보기 전환이 패널 클래스를 통째 덮어 `ui-sidebar-fill` 을
지웠다. 세 자리가 다시 어긋나면 빨강. 화면 수치는 `ui_checks/44-3_좌측패널_하단고정.js`.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OVERLAY = ROOT / "ui_template" / "ui_template_overlay.ts"
PANEL = ROOT / "A00_Common" / "design" / "design_panel.ts"
SHARED = ROOT / "A00_Common" / "design" / "design_shared.ts"
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_split_is_exported_and_idempotent():
src = read(OVERLAY)
assert "export function splitSidebarActions(" in src
assert 'if (body.classList.contains("ui-sidebar-split")) return;' in src
def test_shared_panel_splits_after_attach():
src = read(SHARED)
attach = src.index("shared.left.append(panel.root);")
assert "splitSidebarActions(body)" in src[attach : attach + 400]
def test_panel_keeps_fill_class_and_scroll_host():
src = read(PANEL)
# 보기 전환이 클래스를 통째 덮으면 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다.
assert "root.className = next" not in src
# 늦게 서는 칸은 스크롤 칸 안으로 — 패널 루트에 바로 끼우면 스크롤 밖에 남는다.
assert ":scope > .ui-sidebar-scroll" in src
+10
View File
@@ -80,3 +80,13 @@ def test_일위대가_카드는_상세_카드_갈래():
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
assert "const tools = !!(move || remove);" in rows and "compactTable" not in rows
assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1]
def test_일위대가_상세_단위는_표준_단위_고르기():
"""44-7 — 상세 머리 「단위」 = 표준 단위 목록(UnitPick) · 지금 값이 목록에 없어도 선택지 ·
고르면 캐시만(저장은 상세 [저장]) · 「-」 = 없음."""
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert 'import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";' in detail
assert "extra: [unit0, cur.단위].filter((u): u is string => !!u)," in detail
assert "cur.단위 = u === NO_UNIT ? null : u;" in detail
assert "readOnly: !edit," in detail and "maxLength = 20" not in detail
+3 -1
View File
@@ -39,7 +39,7 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
for cid in ("rv", "c1"):
store.create_structure(cid)
got = store.read("labor", "rv")
logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}]
logic = [{"로직": TEN, "별칭": " 돌붙임 ", "입력값": {}, "수량": {"손": 2}}]
store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic})
personal = layers.personal_dir(7, 42)
items_store.ensure(personal)
@@ -71,6 +71,7 @@ def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world):
c1 = _labor(system, "c1")
assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"])
assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN)
assert c1["담은로직"][0]["별칭"] == "돌붙임" # 별칭도 같이(44-6 · 앞뒤 빈칸 뗌)
assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈
@@ -80,6 +81,7 @@ def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world):
for _ in range(2): # 저장된 데이터도 다시 바꿈
assert client.post(url).status_code == 200
assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN
assert _labor(personal, "a")["담은로직"][0]["별칭"] == "돌붙임"
area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system"
assert client.post(area).status_code == 200
+10 -1
View File
@@ -34,7 +34,7 @@ def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
)
)
assert got["담은로직"] == [
{"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
{"로직": STONE, "별칭": None, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
]
assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2}
assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None)
@@ -47,6 +47,8 @@ def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
_doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직
_doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님
_doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림
_doc(담은로직=[{"로직": STONE, "별칭": 3}]), # 별칭 글 아님(44-6)
_doc(담은로직=[{"로직": STONE, "별칭": "가" * 41}]), # 별칭 40자 넘음
_doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님
_doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번
"문서 아님",
@@ -58,6 +60,13 @@ def test_틀린_문서는_400(bad):
assert e.value.status == 400
def test_별칭은_앞뒤_빈칸_떼고_빈_글은_null():
got = labor.clean(
_doc(담은로직=[{"로직": STONE, "별칭": " 돌붙임 "}, {"로직": TEN, "별칭": " "}])
)
assert [r["별칭"] for r in got["담은로직"]] == ["돌붙임", None]
def test_빈_일위대가도_됨():
got = labor.server(_doc())
assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0
+2 -2
View File
@@ -41,10 +41,10 @@ def test_하위_차례_전체_항목_이름():
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 'return void selectAll("labor");' in side # 해시 open=일위대가
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
assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale
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
+5 -5
View File
@@ -144,7 +144,7 @@ console.log(JSON.stringify([
def test_수량_글은_이은_칸_주소를_보임() -> None:
"""`Labor_View.setQty(수량, 칸)` 의 칸 주소로 「수량 B6 = 20 ㎡」 — 없으면 「면적산출 칸」."""
"""`Labor_View.setQty(수량, 칸)` 의 칸 주소로 「수량 20 ㎡(B6)」 — 없으면 「면적산출 칸」."""
node = shutil.which("node")
if not node:
pytest.skip("node 없음")
@@ -154,11 +154,11 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
# 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글
assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "B6 = 12.5", "B6 = 빈 칸", "—"]
assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "12.5(B6)", "빈 칸(B6)", "—"]
assert got[7:] == [
"수량 B6 = 20 ㎡",
"수량 면적산출 칸 = 20 ㎡",
"수량 20 ㎡(B6)", # 44-6 — 수량(칸 주소)
"수량 20 ㎡(면적산출 칸)",
"수량 손 입력 3 ㎡",
"수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
"수량 빈 칸(B6)", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
"",
]
+1 -1
View File
@@ -87,7 +87,7 @@ def test_주소_인자를_라우트에서_뗌():
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
assert 'query.get("root") ?? query.get("layer")' in layer
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
assert '["구조물조합", "일위대가", "옵션", "도면"]' in layer
assert '["구조물조합", "옵션", "도면"]' in layer # 일위대가 = 구조물 조합 안(44-7)
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
@@ -0,0 +1,82 @@
/* 44-1 · B05 계획노선 편집 구간 재기 — 첫 점 노선 기억(둘째 점 · 커서 미리보기는 그 노선에만)
* 시작 화면: 아무 화면(localhost vite 포트) — 실제 `_RouteEdit_Measure.ts` 를 vite 로 불러 가짜 두 노선에 돌림
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 가짜 노선(화면 = 미터): 계획 = (0,0)→(100,0)→(100,100) 꺾은선 · 예상 = (0,6)→(200,6) 곧은선(계획 위 6px 에 겹침)
* 기대 수치:
* - overlap.second = "planned" · overlap.len = "100.0"(계획 첫 점 (10,0) → 꺾임 지나 (100,10) · 노선 따라 잰 값)
* - otherOnly.marks = 0(계획 첫 점 뒤 예상노선만 닿는 자리 (150,6) = 빗나감 · 예상으로 새로 시작 안 함)
* - previewOther.marks = 1(예상만 닿는 자리 미리보기 없음) · previewOwn = [2, "planned", 50](계획 위 미리보기)
* - expectedFirst.second = "expected" · len = "60.0"(첫 점이 예상이면 계획이 더 가까워도 예상에 붙음)
* - firstMiss.marks = 0(첫 점이 노선 밖이면 지금처럼 아무것도 안 찍힘)
*/
(async () => {
// vite root = A00_Common — 저장소 뿌리는 main.ts 가 옮겨 적은 /@fs/ 주소에서 얻음
const repo = (await (await fetch("/main.ts")).text()).match(
/\/@fs\/[^"']*?\/(?=config\/config_frontend)/,
)[0];
const { createMeasureTool } = await import(
`${repo}B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts`
);
const lines = {
planned: [
[0, 0],
[100, 0],
[100, 100],
],
expected: [
[0, 6],
[200, 6],
],
};
const make = () =>
createMeasureTool({
projectId: "ui-check-none",
stationIntervalM: 20,
lines: () => lines,
toScreen: (v) => [v[0], v[1]],
isClosed: () => true, // 지반고 응답은 버림 — 길이만 본다
onChange: () => {},
});
const out = {};
const lenOf = (tool) => (tool.hint().match(/길이 ([\d.]+)m/) || [])[1] ?? null;
let tool = make();
await tool.pick(10, 1); // 계획 (1px) · 예상 (5px) 둘 다 닿음 → 가까운 계획
await tool.pick(100, 10); // 계획 꺾인 다리(0px) 위 · 예상도 4px
out.overlap = { second: tool.marks()[1]?.line, len: lenOf(tool) };
tool = make();
await tool.pick(10, 1);
await tool.pick(150, 6); // 예상만 닿음
out.otherOnly = { marks: tool.marks().length };
tool = make();
await tool.pick(10, 1);
tool.preview(150, 6);
out.previewOther = { marks: tool.marks().length };
tool.preview(50, 4); // 예상(2px)이 더 가까워도 계획(4px)에 붙어야 함
const m = tool.marks();
out.previewOwn = [m.length, m[1]?.line, m[1]?.point[0]];
tool = make();
await tool.pick(10, 7); // 예상(1px)이 가까움 → 예상
await tool.pick(70, 1); // 계획(1px)이 가까워도 예상(5px)
out.expectedFirst = { second: tool.marks()[1]?.line, len: lenOf(tool) };
tool = make();
await tool.pick(50, 60); // 노선 밖
out.firstMiss = { marks: tool.marks().length };
out.pass =
out.overlap.second === "planned" &&
out.overlap.len === "100.0" &&
out.otherOnly.marks === 0 &&
out.previewOther.marks === 1 &&
out.previewOwn[0] === 2 &&
out.previewOwn[1] === "planned" &&
out.previewOwn[2] === 50 &&
out.expectedFirst.second === "expected" &&
out.expectedFirst.len === "60.0" &&
out.firstMiss.marks === 0;
return JSON.stringify(out);
})();
@@ -0,0 +1,88 @@
/* 44-1b · B05 계획노선 편집 구간 재기 — 실제 화면 배선(찍기 · 커서 미리보기)
* 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(노선이 있는 프로젝트 · 저장 안 함)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: 격자(20px)로 찍어 계획 · 예상 노선 위 자리를 모은 뒤
* 계획 a 를 찍고 → 먼 계획 자리로 커서 이동(미리보기 초록 픽셀 늘어남)
* → 계획에서 먼 예상 자리로 커서 이동(미리보기 사라짐 = a 만 남은 초록 픽셀)
* → 그 예상 자리를 찍음(빗나감 또는 계획에 붙음 · 예상노선으로 안 바뀜) → 계획 a · b 를 찍음(「구간 … 길이」)
* 기대 수치: previewOn > previewBase · previewOff = previewBase · afterOther.text 가 「예상노선」으로 시작 안 함
* · final 에 「길이」 있고 「예상노선」 없음 · 끝나면 잰 것 지우고 재기 단추 끔
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const o = document.querySelector(".b05-routeedit");
const c = o.querySelector("canvas");
const box = o.querySelector(".b05-routeedit__measure");
const text = o.querySelector(".b05-routeedit__measure-text");
const btn = o.querySelector('[data-act="measure"]');
const close = o.querySelector(".b05-routeedit__measure-close");
const r = c.getBoundingClientRect();
// pointerType 이 비면 지도 끌기(pan)가 잡힘 — mouse 로 · 누르면 바로 뗌
const fire = (type, x, y) =>
c.dispatchEvent(
new PointerEvent(type, {
button: 0,
clientX: x,
clientY: y,
pointerId: 1,
pointerType: "mouse",
bubbles: true,
}),
);
const ev = (type, x, y) => {
fire(type, x, y);
if (type === "pointerdown") fire("pointerup", x, y);
};
const green = () => {
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 0; i < d.length; i += 4)
if (d[i] === 34 && d[i + 1] === 197 && d[i + 2] === 94) n++;
return n;
};
if (!btn.classList.contains("is-active")) btn.click();
const hits = { planned: [], expected: [] };
for (let y = r.top + 20; y < r.bottom - 20; y += 20)
for (let x = r.left + 20; x < r.right - 20; x += 20) {
ev("pointerdown", x, y);
if (/시작/.test(text.textContent))
hits[/^예상노선/.test(text.textContent) ? "expected" : "planned"].push([x, y]);
if (!box.hidden) close.click(); // 닫기 = 지움 · 재기 모드도 꺼짐
if (!btn.classList.contains("is-active")) btn.click();
}
const far = (p, list, d) => list.every((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) > d);
const a = hits.planned[0];
const b = hits.planned.find((p) => far(p, [a], 150));
// 계획 자리에서 가장 먼 예상 자리 — 계획노선이 14px 밖이어야 「다른 노선만 닿는 자리」
const gap = (p) => Math.min(...hits.planned.map((q) => Math.hypot(p[0] - q[0], p[1] - q[1])));
const other = hits.expected.reduce((best, p) => (!best || gap(p) > gap(best) ? p : best), null);
const out = { found: { planned: hits.planned.length, expected: hits.expected.length } };
out.otherGapPx = other && Math.round(gap(other));
if (!a || !b || !other) return JSON.stringify({ ...out, error: "자리 못 찾음" });
ev("pointerdown", ...a);
await wait(100);
out.previewBase = green();
ev("pointermove", ...b);
await wait(100);
out.previewOn = green();
ev("pointermove", ...other);
await wait(100);
out.previewOff = green();
ev("pointerdown", ...other);
await wait(50);
out.afterOther = { hidden: box.hidden, text: text.textContent };
ev("pointerdown", ...a);
out.finalA = text.textContent;
ev("pointerdown", ...b);
await wait(50);
out.final = text.textContent;
if (!box.hidden) close.click();
if (btn.classList.contains("is-active")) btn.click();
out.pass =
out.previewOn > out.previewBase &&
out.previewOff === out.previewBase &&
!/^예상노선/.test(out.afterOther.text) &&
/길이/.test(out.final) &&
!/예상노선/.test(out.final);
return JSON.stringify(out);
})();
@@ -0,0 +1,72 @@
/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기
* 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음
* 기대 수치:
* - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반)
* - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기)
* - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
const v = fn();
if (v) return v;
await w(200);
}
return null;
};
const btn = (text) =>
[...document.querySelectorAll("button")].find(
(b) => b.offsetParent && b.textContent.trim() === text,
);
btn("계획노선 편집")?.click();
const canvas = await until(() =>
[...document.querySelectorAll("canvas")].find(
(c) => c.offsetParent && c.closest("[class*='routeedit']"),
),
);
if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" });
// 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀
const url = performance
.getEntriesByType("resource")
.map((e) => e.name)
.find((n) => n.includes("B05_Profile_UI_RouteEdit_Input.ts"));
const input = await import(url);
const boxes = await until(() => {
const b = input.stationLabelBoxes(canvas);
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) },
};
out.ok =
keys.length === 3 &&
keys.every((k) => k.fontSize === "5.5px") &&
out.title.fontSize === out.station.fontSize;
btn("취소")?.click();
return JSON.stringify(out);
})();
@@ -0,0 +1,69 @@
/* 44-3 — B05 · B06 좌측 패널 아래 고정(구조물 목록 · 초기화 · 저장 · 보기 전환 단추 묶음)
* 시작 화면: B05 종단설계(#/b05-profile) · 좌측 패널 열림 · 프로젝트 = 환경 배정 storage 프로젝트
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 기대 수치(B05 → B06 → B05 · 각 보기에서 보통 · 긴 패널 둘):
* - split = true · 패널 루트 자식 = [ui-sidebar-scroll, 단추 묶음] · 칸은 모두 스크롤 칸 안(loose = 0)
* - dockGap = 패널 몸통 바닥 − 단추 묶음 바닥 = 0(±1px)
* - 긴 패널(스크롤 칸에 2000px 임시 칸): overflow = true · dockGap 그대로 0 · 단추 묶음 top 안 바뀜
* - 끝나면 임시 칸 지우고 처음 주소로 되돌림
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const start = location.hash;
const measure = async (tag) => {
const panel = document.querySelector(".b05-route__panel, .b06-profile__form");
if (!panel) return { tag, error: "패널 없음" };
const body = panel.closest(".ui-workflow-overlay__body");
const scroll = panel.querySelector(":scope > .ui-sidebar-scroll");
const dock = panel.querySelector(":scope > .ui-sidebar-actions");
const kids = [...panel.children].map((c) => c.classList[0]);
const gap = () =>
Math.round(body.getBoundingClientRect().bottom - dock.getBoundingClientRect().bottom);
const res = {
tag,
view: panel.classList.contains("b06-profile__form") ? "B06" : "B05",
split: body.classList.contains("ui-sidebar-split"),
kids,
loose: kids.filter((k) => k !== "ui-sidebar-scroll" && !dock?.classList.contains(k)).length,
dockGap: dock ? gap() : null,
};
if (!scroll || !dock) return res;
const top0 = Math.round(dock.getBoundingClientRect().top);
const tall = document.createElement("div");
tall.style.cssText = "height: 2000px; flex: none";
scroll.append(tall);
await w(100);
res.long = {
overflow: scroll.scrollHeight > scroll.clientHeight,
dockGap: gap(),
dockMoved: Math.round(dock.getBoundingClientRect().top) - top0,
};
tall.remove();
res.ok =
res.split &&
res.loose === 0 &&
Math.abs(res.dockGap) <= 1 &&
res.long.overflow &&
Math.abs(res.long.dockGap) <= 1 &&
res.long.dockMoved === 0;
return res;
};
const go = async (hash) => {
location.hash = hash;
for (let i = 0; i < 40; i++) {
await w(250);
const want = hash.includes("b06") ? ".b06-profile__form" : ".b05-route__panel";
if (document.querySelector(want + " > .ui-sidebar-actions")) break;
}
await w(500);
};
const out = [];
await go("#/b05-profile");
out.push(await measure("B05"));
await go("#/b06-section");
out.push(await measure("B06"));
await go("#/b05-profile");
out.push(await measure("B05 다시"));
if (location.hash !== start) await go(start);
return JSON.stringify({ ok: out.every((r) => r.ok), out });
})();
@@ -0,0 +1,100 @@
/* 44-6 일위대가 단가산출 로직 표 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
*
* 시작 화면: M02 마스터 템플릿(시스템 관리자) · 좌측 일위대가 줄 「SU-000003」 이 보이는 상태 ·
* 첫 로직 줄(GF000219 돌쌓기)에 고르기 입력이 있음 · 저장은 안 함(끝에 [다시 열기] → 확인 → 버림).
* 기대 수치:
* heads[2] = "별칭" · alias.inputs = 줄 수 · foot.totalBorder ≥ 1.5(2px · 화면 배율) · foot.totalBg ≠ 다른 금액 칸 배경
* opt.row = true · opt.colspan = 8(이름 ~ 합계) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" ·
* opt.height < 30(한 줄) · opt.mainGrow ≤ 2(옵션을 넣어도 위 줄 높이 그대로) · opt.title = 옵션 글 전체
* alias.noModal = true(칸 누름이 입력 모달을 안 엶) · alias.dirty = true · alias.kept = "시험별칭"(다시 그려도 남음)
* discarded = true(버린 뒤 별칭 빈 칸 · 옵션 줄 처음과 같음)
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const btn = (root, text) => $$("button", root).find((b) => b.innerText.trim() === text);
const out = {};
const side = $$(".m02-side__item").find((b) => b.innerText.startsWith("SU-000003"));
if (!side) return JSON.stringify({ error: "SU-000003 줄 없음" });
side.click();
await W(5000);
const table = () => $(".m02-labrow");
const first = () => $("tbody tr[data-labor-logic]", table());
const key = first()?.getAttribute("data-labor-logic");
if (!key) return JSON.stringify({ error: "로직 줄 없음" });
const optRow = () => $(`tr[data-labor-opt="${key}"]`, table());
out.heads = $$("thead th", table()).map((t) => t.innerText.trim());
// ③ 전체 합계 칸
const total = $("tfoot .m02-labrow__total", table());
const other = $$("tfoot .m02-labrow__money", table())[0];
const cs = total && getComputedStyle(total);
out.foot = {
total: !!total,
totalBorder: cs ? parseFloat(cs.borderTopWidth) : 0,
totalBg: cs?.backgroundColor,
otherBg: getComputedStyle(other).backgroundColor,
};
out.foot.colored = out.foot.totalBg !== out.foot.otherBg;
// ② 옵션 값 줄 — 입력 모달에서 고르기 칸을 모두 끝 값으로
const optBefore = optRow()?.innerText ?? null;
const mainBefore = first().getBoundingClientRect().height;
$(".m02-labrow__name", first()).click();
await W(500);
const im = $(".m02-labin");
const picked = [];
for (const s of $$("select[data-labor-input]", im)) {
const opts = [...s.options].filter((o) => o.value);
if (!opts.length) continue;
s.value = opts[opts.length - 1].value;
s.dispatchEvent(new Event("change", { bubbles: true }));
picked.push(s.getAttribute("data-labor-input"));
}
btn(im, "확인")?.click();
await W(3500);
const o = optRow();
const td = o && $(".m02-labrow__opt", o);
const tcs = td && getComputedStyle(td);
out.opt = {
picked,
row: !!o,
colspan: td ? Number(td.getAttribute("colspan")) : null,
nowrap: tcs?.whiteSpace,
ellipsis: tcs?.textOverflow,
height: o ? Math.round(o.getBoundingClientRect().height) : null,
mainGrow: Math.round(first().getBoundingClientRect().height - mainBefore),
title: td?.getAttribute("title"),
text: td?.innerText,
inName: !!$(".m02-labrow__name .m02-labrow__note", first())?.innerText.includes(
picked[0] ?? "\u0000",
),
};
// ④ 별칭 — 칸 누름 · 입력 · 다시 그려도 남음
out.alias = { inputs: $$("[data-labor-alias]", table()).length };
const ai = $("[data-labor-alias]", first());
ai.click();
await W(300);
out.alias.noModal = !$(".m02-labin");
ai.value = "시험별칭";
ai.dispatchEvent(new Event("input", { bubbles: true }));
await W(200);
out.alias.dirty =
!!btn($(".m02-main__head"), "저장") && !btn($(".m02-main__head"), "저장").disabled;
// 다시 그림(입력 모달 확인 → 다시 셈) 뒤에도 남는지
$(".m02-labrow__name", first()).click();
await W(500);
btn($(".m02-labin"), "확인")?.click();
await W(3500);
out.alias.kept = $("[data-labor-alias]", first())?.value;
// 버림
btn($(".m02-main__head"), "다시 열기").click();
await W(600);
btn($(".ui-confirm"), "확인")?.click();
await W(4500);
out.after = {
alias: $("[data-labor-alias]", first())?.value,
opt: optRow()?.innerText ?? null,
};
out.discarded = out.after.alias === "" && out.after.opt === optBefore;
return JSON.stringify(out);
})();
@@ -0,0 +1,78 @@
/* 44-7 — ① M02 일위대가 상세 머리 「단위」 = 표준 단위 드롭다운 ② 대시보드 「시스템 설정」 일위대가 카드 없음
* 시작 화면: 아무 화면 · 시스템 관리자 로그인 · M02 마스터 층에 구조물 열 하나 이상
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안)
* 기대 수치:
* - cards = ["마스터 데이터", "구조물 조합", "구조물 옵션", "도면 양식"] · hasLabor = false
* - pick = 1(단추) · textInput = 0(손글 칸 없음) · options > 10 · hasNow = true(지금 값이 선택지에 있음)
* - 다른 단위 고름 → after = 고른 값 · dirty = true(다른 열로 가려면 버림 물음 · 「취소」)
* - 처음 값 다시 고름 → back = 처음 값 · dirtyBack = false(물음 없이 옮겨 감 · 저장 안 함 · 바꾼 값 되돌림)
*/
(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 out = {};
// ② 대시보드 시스템 설정 카드
location.hash = "#/b01-account";
await until(() => document.querySelector(".b01-dashboard__setting-title"));
out.cards = [...document.querySelectorAll(".b01-dashboard__setting-title")].map(
(e) => e.textContent,
);
out.hasLabor = out.cards.includes("일위대가");
// ① M02 일위대가 상세(좌측 일위대가 하위 첫 줄)
location.hash = "#/m02-master-template?open=구조물조합&root=master";
// 하위 줄은 다시 그려짐 — 누를 때마다 새로 찾음
const row = () =>
document
.querySelector('[data-all="labor"]')
?.parentElement?.querySelector(".m02-side__item:not([data-all])");
if (!(await until(row))) return JSON.stringify({ ...out, error: "일위대가 하위 없음" });
await w(500);
row().click();
const field = await until(() => document.querySelector('[data-labor-field="단위"]'));
if (!field) return JSON.stringify({ ...out, error: "단위 칸 없음" });
// 고침 표시 = 다른 열(하위 둘째 줄)로 갈 때 버림 물음이 뜨는지 — 뜨면 「취소」
const asks = async () => {
document
.querySelector('[data-all="labor"]')
.parentElement.querySelectorAll(".m02-side__item:not([data-all])")[1]
.click();
const ask = await until(() => document.querySelector(".ui-confirm"), 1500);
[...(ask?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "취소")?.click();
await w(200);
return !!ask;
};
const btn = () => document.querySelector('[data-labor-field="단위"] .m02-df-unitpick > button');
const items = async () => {
btn().click();
return until(() => {
const li = document.querySelectorAll('[data-labor-field="단위"] .m02-df-unitpick__item');
return li.length ? [...li] : null;
});
};
out.pick = document.querySelectorAll('[data-labor-field="단위"] .m02-df-unitpick').length;
out.textInput = field.querySelectorAll("input[type=text]:not(.m02-df-unitpick__filter)").length;
const u0 = btn().textContent;
out.before = u0;
const list = await items();
out.options = list.length;
out.hasNow = list.some((b) => b.classList.contains("is-now"));
const other = list.find((b) => !b.classList.contains("is-now") && !b.textContent.startsWith("-"));
other.click();
await w(200);
out.after = btn().textContent;
out.dirty = await asks();
// 되돌림 — 처음 값(없음이면 「-」)
const again = await items();
again.find((b) => (u0 === "-" ? b.textContent.startsWith("-") : b.textContent === u0)).click();
await w(200);
out.back = btn().textContent;
out.dirtyBack = await asks();
return JSON.stringify(out);
})();
-1
View File
@@ -4,7 +4,6 @@
* ========================================================================== */
export const ui_locales_m2 = {
B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"],
B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"],
B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"],
B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"],
+3 -1
View File
@@ -112,7 +112,9 @@ function readOpenState(key: string, collapseWhenNarrow = false): boolean {
* 아예 빼고, 나머지 그룹만 마진·패딩 없는 래퍼(`.ui-sidebar-scroll`)에 담아 그쪽만
* 스크롤시킨다. 페이지는 지금처럼 본문 마지막에 액션 줄을 붙이기만 하면 된다.
*/
function splitSidebarActions(body: HTMLElement): void {
export function splitSidebarActions(body: HTMLElement): void {
// 이미 쪼갠 body 는 그대로 — 패널을 늦게 붙이는 화면(B05·B06 한 페이지)이 붙인 뒤 다시 부른다.
if (body.classList.contains("ui-sidebar-split")) return;
// 액션 줄은 보통 페이지 패널 루트(b05-route__panel 등) **안**에 있다 — body 직계가
// 아니므로 깊이 무관하게 찾고, 액션 줄의 실제 부모 기준으로 쪼갠다.
const actions = body.querySelector<HTMLElement>(".ui-sidebar-actions");