feat(B09): 설계내역서 방향키 · 줄 끌기(자리 · 레벨) · 자손 함께 · 묶음/항목 자동 (204-1)
- 고른 줄 방향키 → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로 · Alt+←→ 안 받음) - 줄 끌기 = 위아래 자리 · 좌우 레벨 · 미리 보기 선 · 못 놓는 자리 빨간 점선(공용 표 안 고침) - 자식이 생긴 항목 = 저절로 묶음(Calc_Bill 짝 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음) - 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) · 자재 · 조달수수료 줄 = 부모 못 됨 - 시험 test_204_1 새로 · 184_3 · 166_2 · 200_2 메뉴 · 키 기대값 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) ·
|
||||
`test_184_2_b09_bill_free.py`(자유 편집).
|
||||
`test_184_2_b09_bill_free.py`(자유 편집) · `test_204_1_b09_bill_drag.py`(묶음 자동).
|
||||
|
||||
실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔):
|
||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||
@@ -33,7 +33,9 @@
|
||||
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
|
||||
기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) ·
|
||||
원천이 안 낸 키 = 안 보임 ·
|
||||
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||
부모 못 찾음 · 자재 줄(`mat:`) · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||
항목 줄에 자식이 생김 = 저절로 묶음(204 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음 —
|
||||
자식이 다 빠지면 다시 항목) ·
|
||||
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
|
||||
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` ·
|
||||
`after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2).
|
||||
@@ -331,7 +333,7 @@ def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tup
|
||||
|
||||
|
||||
def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||
"""부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||
관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section
|
||||
(깊이 5 넘음 = 레벨 4 조상 아래)."""
|
||||
by_key = {row["key"]: row for row in order}
|
||||
@@ -353,7 +355,7 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
up = by_key.get(row.get("parent") or "")
|
||||
if row["key"] == "sec:G":
|
||||
row["parent"] = None
|
||||
elif up is None or up["kind"] == "item" or up is row:
|
||||
elif up is None or up["key"].startswith("mat:") or up is row:
|
||||
row["parent"] = row["_home"] if row["_home"] in by_key else None
|
||||
for row in order:
|
||||
if loops(row):
|
||||
@@ -383,6 +385,11 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
row["section"] = chain[-1]["section"] if chain else None
|
||||
rows.append(row)
|
||||
stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], [])))
|
||||
parents = {row["parent"] for row in rows}
|
||||
for row in rows: # 자식이 생긴 항목 = 저절로 묶음(204)
|
||||
if row["kind"] == "item" and row["key"] in parents:
|
||||
row.update({"kind": "group", "qty": None, "ref": None, "_gaps": []})
|
||||
row.update({f"price_{p}": None for p in (*PARTS, "sum")})
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집).
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집) ·
|
||||
* `test_204_1_b09_bill_drag.py`(묶음 자동).
|
||||
*
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
@@ -304,7 +305,8 @@ function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||
/** 부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||
* 자식이 생긴 항목 = 저절로 묶음(204 · 자식이 다 빠지면 다시 항목) →
|
||||
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
|
||||
function tree(order: Row[]): Row[] {
|
||||
const byKey = new Map<string, Row>();
|
||||
@@ -325,7 +327,7 @@ function tree(order: Row[]): Row[] {
|
||||
for (const row of order) {
|
||||
const up = byKey.get(row.parent ?? "");
|
||||
if (row.key === "sec:G") row.parent = null;
|
||||
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
|
||||
else if (!up || up.key.startsWith("mat:") || up === row) row.parent = home(row);
|
||||
}
|
||||
for (const row of order) {
|
||||
if (!loops(row)) continue;
|
||||
@@ -354,6 +356,13 @@ function tree(order: Row[]): Row[] {
|
||||
rows.push(row);
|
||||
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
|
||||
}
|
||||
const parents = new Set(rows.map((row) => row.parent));
|
||||
for (const row of rows) {
|
||||
// 자식이 생긴 항목 = 저절로 묶음(204)
|
||||
if (row.kind !== "item" || !parents.has(row.key)) continue;
|
||||
Object.assign(row, { kind: "group", qty: null, ref: null, _gaps: [] });
|
||||
for (const p of [...PARTS, "sum"]) row[`price_${p}`] = null;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -209,3 +209,16 @@
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */
|
||||
.b09-estimation__drop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--color-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b09-estimation__drop.is-bad {
|
||||
border-top: 2px dashed var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
billMenu,
|
||||
dropMove,
|
||||
editCell,
|
||||
linkable,
|
||||
openLinkMenu,
|
||||
@@ -96,6 +97,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
|
||||
},
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
|
||||
onCell: (i, key) => {
|
||||
if (key === "connect") openLinkMenu(ctx, raw, i);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Drag.ts
|
||||
* B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림.
|
||||
*
|
||||
* 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음
|
||||
* 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만
|
||||
* 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed)
|
||||
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
export interface DropAt {
|
||||
parent: string | null;
|
||||
before: string | null;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const holds = (row: DataRow): boolean => row.key !== "fee" && !String(row.key).startsWith("mat:");
|
||||
|
||||
/** 틈 gap(0 ~ 줄 수 · 그 줄 앞) · 바라는 레벨 → 놓을 자리 · 같은 자리 · 못 놓음 = null */
|
||||
export function dropAt(rows: DataRow[], from: number, gap: number, want: number): DropAt | null {
|
||||
const row = rows[from];
|
||||
if (!row) return null;
|
||||
const lv = (r: DataRow | undefined): number => Number(r?.level ?? 1);
|
||||
let end = from + 1;
|
||||
while (end < rows.length && lv(rows[end]) > lv(row)) end += 1;
|
||||
if (gap > from && gap < end) return null; // 자기 자손 안
|
||||
const rest = [...rows.slice(0, from), ...rows.slice(end)];
|
||||
const g = gap <= from ? gap : gap - (end - from);
|
||||
const up = rest[g - 1];
|
||||
const down = rest[g];
|
||||
const max = up ? lv(up) + (holds(up) ? 1 : 0) : 1;
|
||||
const min = Math.max(down ? lv(down) : 1, lv(row) === 1 ? 1 : 2);
|
||||
if (min > max) return null;
|
||||
const level = Math.min(max, Math.max(min, want));
|
||||
if (level === lv(row) && (gap === from || gap === end)) return null; // 제자리
|
||||
let parent: DataRow | undefined = up;
|
||||
const byKey = new Map(rest.map((r) => [String(r.key), r]));
|
||||
while (parent && lv(parent) >= level) parent = byKey.get(String(parent.parent ?? ""));
|
||||
return {
|
||||
parent: parent ? String(parent.key) : null,
|
||||
before: down && lv(down) === level ? String(down.key) : null,
|
||||
level,
|
||||
};
|
||||
}
|
||||
|
||||
/** 표 root 에 줄 끌기를 붙임 — 놓으면 done(끈 줄 키) 뒤 drop 실행 */
|
||||
export function attachDrag(root: HTMLElement, table: BillTable, done: (key: string) => void): void {
|
||||
let endAt = 0;
|
||||
// 끌고 난 누름 = 줄 고름 아님
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (Date.now() - endAt < 300) event.stopPropagation();
|
||||
},
|
||||
true,
|
||||
);
|
||||
root.addEventListener("pointerdown", (down) => {
|
||||
const target = down.target as Element;
|
||||
if (down.button !== 0 || !table.drop || target.closest("input,textarea,select,button,a"))
|
||||
return;
|
||||
const tr = target.closest<HTMLElement>("tr[data-i]");
|
||||
if (!tr || !root.contains(tr)) return;
|
||||
const from = Number(tr.dataset.i);
|
||||
const step = parseFloat(getComputedStyle(tr).fontSize) || 14;
|
||||
const line = document.createElement("div");
|
||||
line.className = "b09-estimation__drop";
|
||||
let on = false;
|
||||
let run: (() => void) | null = null;
|
||||
let seen = "";
|
||||
const move = (event: PointerEvent): void => {
|
||||
const dx = event.clientX - down.clientX;
|
||||
if (!on && Math.abs(dx) + Math.abs(event.clientY - down.clientY) < 6) return;
|
||||
if (!on) {
|
||||
on = true;
|
||||
document.body.append(line);
|
||||
document.body.style.userSelect = "none";
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
const trs = table.rows.map((_r, i) => root.querySelector<HTMLElement>(`tr[data-i="${i}"]`));
|
||||
let gap = trs.length;
|
||||
let y = trs.at(-1)?.getBoundingClientRect().bottom ?? 0;
|
||||
for (const [i, el] of trs.entries()) {
|
||||
const box = el?.getBoundingClientRect();
|
||||
if (box && event.clientY < box.top + box.height / 2) {
|
||||
[gap, y] = [i, box.top];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const want = Number(table.rows[from]?.level ?? 1) + Math.round(dx / step);
|
||||
const at = dropAt(table.rows, from, gap, want);
|
||||
const key = `${gap}|${at?.level}`;
|
||||
if (key !== seen) {
|
||||
seen = key;
|
||||
run = at ? table.drop!(from, at.parent, at.before) : null;
|
||||
}
|
||||
const box = root.getBoundingClientRect();
|
||||
const left = box.left + ((at?.level ?? want) - 1) * step + 8;
|
||||
Object.assign(line.style, {
|
||||
top: `${y - 1}px`,
|
||||
left: `${left}px`,
|
||||
width: `${Math.max(box.right - left, 40)}px`,
|
||||
});
|
||||
line.classList.toggle("is-bad", !run);
|
||||
document.body.style.cursor = run ? "grabbing" : "not-allowed";
|
||||
};
|
||||
const stop = (event: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
line.remove();
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
if (!on) return;
|
||||
endAt = Date.now();
|
||||
if (event.type === "pointerup" && run) {
|
||||
done(String(table.rows[from].key));
|
||||
run();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
|
||||
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
|
||||
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
|
||||
* 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 ·
|
||||
* 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -24,7 +26,11 @@ import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
|
||||
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
|
||||
export type BillTable = TabTable & {
|
||||
menu?: (index: number) => MapContextMenuItem[];
|
||||
/** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */
|
||||
drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null;
|
||||
};
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const NEW_SECTION = "새 공종";
|
||||
@@ -47,6 +53,8 @@ interface Work {
|
||||
|
||||
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
|
||||
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
|
||||
/** 자식을 둘 수 있는 줄 — 자재 · 조달수수료 빼고(항목은 저절로 묶음) */
|
||||
const canHold = (key: string): boolean => key !== "fee" && !key.startsWith("mat:");
|
||||
|
||||
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
|
||||
export const linkable = (row: DataRow | undefined): boolean =>
|
||||
@@ -148,6 +156,11 @@ function attempt(
|
||||
const before = w.bill.rows.map((e) => ({ ...e }));
|
||||
if (change(w) === false) return null;
|
||||
w.bill.rows = tidy(w.bill.rows);
|
||||
// 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) — 자식이 생긴 항목 = 셈이 묶음으로
|
||||
const had = new Set(before.map((e) => e.parent));
|
||||
const has = new Set(w.bill.rows.map((e) => e.parent));
|
||||
for (const e of w.bill.rows)
|
||||
if (e.kind === "group" && e.parent && had.has(e.key) && !has.has(e.key)) delete e.kind;
|
||||
return fits(w, before) ? () => put(ctx, w.bill) : null;
|
||||
}
|
||||
|
||||
@@ -273,7 +286,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
add(2, "들여쓰기 →", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const up = sib[sib.indexOf(e) - 1];
|
||||
if (!up || !isGroup(w, up.key)) return false;
|
||||
if (!up || !canHold(up.key)) return false;
|
||||
e.parent = up.key;
|
||||
w.bill.rows.push(pull(w.bill.rows, key));
|
||||
});
|
||||
@@ -315,6 +328,28 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 끌어 놓기(204) — 자손째 parent 아래 before 앞으로 · 지킬 것(깊이 · 자재 돈 갈래)은 메뉴와 같음 */
|
||||
export function dropMove(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
i: number,
|
||||
parent: string | null,
|
||||
before: string | null,
|
||||
): (() => void) | null {
|
||||
const key = String(rows[i]?.key);
|
||||
if (!rows[i] || fixed(key) || (parent !== null && !canHold(parent))) return null;
|
||||
return attempt(ctx, rows, (w) => {
|
||||
const e = entryOf(w, key);
|
||||
if (!e || (parent !== null && !entryOf(w, parent))) return false;
|
||||
if (parent !== null && chain(w.bill.rows, parent).includes(e)) return false; // 자기 자손 아래
|
||||
if (parent === null) delete e.parent;
|
||||
else e.parent = parent;
|
||||
pull(w.bill.rows, key);
|
||||
const at = before === null ? -1 : find(w.bill.rows, before);
|
||||
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, e);
|
||||
});
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
|
||||
let lastCell: Element | null = null;
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
|
||||
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
|
||||
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
|
||||
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag";
|
||||
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */
|
||||
@@ -21,11 +23,14 @@ const ACTS: [string, string][] = [
|
||||
["↓ 아래로", "아래로 ↓"],
|
||||
];
|
||||
|
||||
const GUIDE = "레벨 — 줄을 누르고 단추 · 키 Tab 들여 · Shift+Tab 내어 · Alt+↑↓ 옮김";
|
||||
const GUIDE =
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
|
||||
const GUIDE_MORE =
|
||||
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
|
||||
"들여쓰기 = 바로 위 묶음 줄 아래로 · 내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
|
||||
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
|
||||
"내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
|
||||
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
|
||||
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
|
||||
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
|
||||
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
|
||||
|
||||
@@ -56,8 +61,11 @@ export function keyAct(
|
||||
): string | null {
|
||||
if (editing || e.ctrlKey) return null;
|
||||
if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1];
|
||||
if (!e.altKey || e.shiftKey) return null;
|
||||
return e.key === "ArrowUp" ? ACTS[2][1] : e.key === "ArrowDown" ? ACTS[3][1] : null;
|
||||
if (e.shiftKey) return null;
|
||||
const arrow = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].indexOf(e.key);
|
||||
// 방향키(204) — 그냥 · Alt 같음(Alt+←→ = 브라우저 뒤로 · 앞으로라 안 받음)
|
||||
if (arrow < 0 || (e.altKey && arrow < 2)) return null;
|
||||
return ACTS[arrow][1];
|
||||
}
|
||||
|
||||
const editing = (at: Element | null): boolean =>
|
||||
@@ -122,6 +130,7 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
|
||||
picked = String(row.key);
|
||||
sync();
|
||||
});
|
||||
attachDrag(root, table, (key) => (picked = key));
|
||||
sync();
|
||||
live = { root, bar, run, sync };
|
||||
listenKeys();
|
||||
|
||||
@@ -229,8 +229,9 @@ console.log(JSON.stringify(out));
|
||||
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
|
||||
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
|
||||
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
# 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음)
|
||||
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
|
||||
"내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
"들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
|
||||
above = out["above"]
|
||||
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
|
||||
|
||||
@@ -125,10 +125,13 @@ def test_메뉴_모든_줄():
|
||||
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
|
||||
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
|
||||
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
|
||||
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
|
||||
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
|
||||
"내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
|
||||
"아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
"아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
||||
|
||||
@@ -122,7 +122,8 @@ out.keyAct = [
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"]
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
|
||||
"UI_Tabs_Bill_Level"] # fmt: skip
|
||||
IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"
|
||||
|
||||
|
||||
@@ -168,7 +169,8 @@ def test_키_셋_편집_중엔_안_먹음():
|
||||
# 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작
|
||||
assert out["outside"] == [False, []]
|
||||
assert out["unpick"] == [[True] * 4, False, False]
|
||||
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None]
|
||||
# 그냥 ↑ = 옮기기(204 방향키 · test_204_1)
|
||||
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None]
|
||||
|
||||
|
||||
def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""204-1 B09 설계내역서 — 방향키 · 끌기 레벨 · 이동 · 자손 함께 · 묶음 자동 · 합 전달.
|
||||
|
||||
- 방향키 = 고른 줄(편집 중 아님) → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로) · Alt+←→ 안 받음
|
||||
- 끌기 자리 = `dropAt`(틈 · 바라는 레벨 → 부모 · 앞 줄) ·
|
||||
놓기 = 표 drop(`dropMove` · 메뉴와 같은 지킬 것)
|
||||
- 묶음 자동 = 셈(짝 `Calc_Bill` · 거울) — 자식이 생긴 항목 = 묶음(자손 합 · 위로 전달) ·
|
||||
자식이 빠지면 다시 항목(제 수량 · 단가) · 자식이 다 빠진 새 묶음 = 항목(화면 · 맨 위 공종 빼고)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_184_2_b09_bill_free import ( # noqa: E402
|
||||
BASE,
|
||||
BOOKS,
|
||||
DG,
|
||||
INPUTS,
|
||||
_calc,
|
||||
_freeze,
|
||||
_put,
|
||||
_rows,
|
||||
)
|
||||
from test_184_2_b09_bill_free import _node as _node_calc # noqa: E402
|
||||
from test_184_3_b09_bill_menu import ROWS, _node, _row, _ts # noqa: E402
|
||||
from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
|
||||
"UI_Tabs_Bill_Level"] # fmt: skip
|
||||
|
||||
|
||||
def _order(bill):
|
||||
return [(e["key"], e.get("parent"), e.get("kind")) for e in (bill or {}).get("rows", [])]
|
||||
|
||||
|
||||
# --- 방향키 -----------------------------------------------------------------------------
|
||||
|
||||
KEYS_JS = """
|
||||
const out = {};
|
||||
const press = (pickKey, k, more = {}) => {
|
||||
user = {}; draw(); pick(pickKey); root.focus();
|
||||
return [key(k, more), order()];
|
||||
};
|
||||
out.right = press("earth:5", "ArrowRight");
|
||||
out.left = press("earth:4", "ArrowLeft");
|
||||
out.up = press("earth:5", "ArrowUp");
|
||||
out.down = press("earth:4", "ArrowDown");
|
||||
out.altLeft = press("earth:4", "ArrowLeft", { altKey: true });
|
||||
user = {}; draw(); pick("earth:5");
|
||||
doc.activeElement = tr("earth:5").kids[0].kids[0];
|
||||
out.editing = [key("ArrowRight"), key("ArrowUp"), order()];
|
||||
out.guide = bar.kids.at(-1).textContent;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_방향키_넷_편집_중엔_안_먹음():
|
||||
head = BAR_JS[: BAR_JS.index("const out = {};")]
|
||||
body = head.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + KEYS_JS
|
||||
out = json.loads(_node(_ts(SRC) + body))
|
||||
stopped, order = out["right"] # → = 위 항목 아래로(earth:4 = 저절로 묶음)
|
||||
assert stopped and ["earth:5", "earth:4"] in order
|
||||
stopped, order = out["left"] # ← = 1.1 밖으로(1 아래)
|
||||
assert stopped and ["earth:4", "sec:1"] in order
|
||||
for name in ("up", "down"): # ↑↓ = 같은 부모 안 자리 바꿈
|
||||
stopped, order = out[name]
|
||||
keys = [k for k, _ in order]
|
||||
assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name
|
||||
assert out["altLeft"] == [False, []] # Alt+← = 브라우저 뒤로 — 안 받음
|
||||
assert out["editing"] == [False, False, []] # 글 칸 편집 중 = 글 칸 커서
|
||||
assert "←→" in out["guide"] and "↑↓" in out["guide"] and "끌기" in out["guide"]
|
||||
|
||||
|
||||
# --- 끌기 자리 · 놓기 ------------------------------------------------------------------------
|
||||
|
||||
DROP_JS = """
|
||||
const start = __USER__;
|
||||
let user = start;
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const table = buildBill(ctx);
|
||||
const at = (k) => table.rows.findIndex((r) => r.key === k);
|
||||
const order = (b) => (b?.rows ?? []).map((e) => [e.key, e.parent ?? null, e.kind ?? null]);
|
||||
const out = { at: {}, drop: {} };
|
||||
for (const [name, k, gap, want] of __ATS__) out.at[name] = dropAt(table.rows, at(k), gap, want);
|
||||
for (const [name, k, parent, before] of __DROPS__) {
|
||||
user = start;
|
||||
const run = table.drop(at(k), parent, before);
|
||||
if (run) run();
|
||||
out.drop[name] = run ? order(user.bill) : null;
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
#: ROWS + 맨 위 새 공종(hand:n · 자식 hand:k) — 4 자재대 뒤
|
||||
ROWS2 = [*ROWS[:9], _row("hand:n", None, 1, "group", no="5"), _row("hand:k", "hand:n", 2),
|
||||
*ROWS[9:]] # fmt: skip
|
||||
|
||||
|
||||
def _drag(ats=(), drops=(), rows=ROWS2, user=None):
|
||||
body = (
|
||||
DROP_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__USER__", json.dumps(user or {}, ensure_ascii=False))
|
||||
.replace("__ATS__", json.dumps([list(a) for a in ats], ensure_ascii=False))
|
||||
.replace("__DROPS__", json.dumps([list(d) for d in drops], ensure_ascii=False))
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + body))
|
||||
|
||||
|
||||
def test_끌기_자리_레벨():
|
||||
# 차례 0 sec:1 · 1 sec:1.2 · 2 earth:4 · 3 earth:5 · 4 hand:g · 5 hand:a · 6 hand:b · 7 sec:4 ·
|
||||
# 8 mat:M1 · 9 hand:n · 10 hand:k · 11 sec:G · 12 mat:M2 · 13 fee
|
||||
out = _drag(
|
||||
ats=[
|
||||
("오른쪽", "earth:5", 3, 4), # 제자리 + 한 칸 오른쪽 = 위 항목 아래로
|
||||
("제자리", "earth:5", 3, 3),
|
||||
("제자리끝", "earth:5", 4, 3),
|
||||
("자손안", "hand:g", 5, 3),
|
||||
("위로", "hand:g", 2, 3),
|
||||
("멀리오른쪽", "hand:g", 2, 9), # 레벨 = 위 줄 + 1 까지
|
||||
("자재아래", "earth:5", 9, 3), # 자재 줄 = 자식 못 둠 → 형제로
|
||||
("맨위안됨", "earth:5", 7, 1), # 항목 = 맨 위(1) 못 감 → 2
|
||||
("맨위공종", "sec:4", 0, 1),
|
||||
]
|
||||
)["at"]
|
||||
assert out["오른쪽"] == {"parent": "earth:4", "before": None, "level": 4}
|
||||
assert out["제자리"] is None and out["제자리끝"] is None and out["자손안"] is None
|
||||
assert out["위로"] == {"parent": "sec:1.2", "before": "earth:4", "level": 3}
|
||||
assert out["멀리오른쪽"] == out["위로"]
|
||||
assert out["자재아래"] == {"parent": "sec:4", "before": None, "level": 2}
|
||||
assert out["맨위안됨"] == {"parent": "sec:1", "before": None, "level": 2}
|
||||
assert out["맨위공종"] == {"parent": None, "before": "sec:1", "level": 1}
|
||||
|
||||
|
||||
def test_놓기_자손_함께_묶음_자동_막음():
|
||||
out = _drag(
|
||||
drops=[
|
||||
("자손", "hand:g", "sec:1.2", "earth:4"),
|
||||
("항목아래", "earth:5", "earth:4", None),
|
||||
("하나빠짐", "hand:a", "sec:1.2", "hand:b"),
|
||||
("맨위남음", "hand:k", "sec:4", None),
|
||||
("관급밖", "mat:M2", "sec:1", None),
|
||||
("수수료", "fee", "sec:1", None),
|
||||
("관급제목", "sec:G", None, "sec:1"),
|
||||
("자재아래", "earth:5", "mat:M1", None),
|
||||
("자기자손", "hand:g", "hand:a", None),
|
||||
]
|
||||
)["drop"]
|
||||
keys = [k for k, *_ in out["자손"]]
|
||||
# 묶음 통째 — 자식 hand:a 는 부모 키 그대로 · 차례 = hand:g 바로 뒤
|
||||
assert keys[keys.index("hand:g") + 1] == "hand:a" and keys.index("hand:g") < keys.index(
|
||||
"earth:4"
|
||||
)
|
||||
assert ["hand:a", "hand:g", None] in out["자손"]
|
||||
assert ["earth:5", "earth:4", None] in out["항목아래"] # 저장 = 부모만(묶음 = 셈이 정함)
|
||||
# 자식이 다 빠진 새 묶음 = 항목(kind 지움) · 맨 위 공종 = 묶음 그대로
|
||||
assert ["hand:g", "sec:1.2", None] in out["하나빠짐"]
|
||||
assert ["hand:n", None, "group"] in out["맨위남음"]
|
||||
for name in ("관급밖", "수수료", "관급제목", "자재아래", "자기자손"):
|
||||
assert out[name] is None, name
|
||||
|
||||
|
||||
def test_깊이_5_넘는_놓기_막음():
|
||||
deep = [
|
||||
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
|
||||
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
|
||||
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
|
||||
_row("hand:y", "sec:1.2", 3),
|
||||
] # fmt: skip
|
||||
out = _drag(drops=[("깊음", "hand:g", "hand:y", None), ("됨", "hand:z", "hand:y", None)],
|
||||
rows=deep)["drop"] # fmt: skip
|
||||
assert out["깊음"] is None
|
||||
assert ["hand:z", "hand:y", None] in out["됨"]
|
||||
|
||||
|
||||
# --- 셈 — 묶음 ↔ 항목 자동 · 합 전달 · 거울 -----------------------------------------------------
|
||||
|
||||
HANDS = [
|
||||
{"key": "hand:a", "parent": "sec:3", "name": "a", "unit": "㎡", "qty": 2, "ref": DG},
|
||||
{"key": "hand:c", "parent": "hand:a", "name": "c", "unit": "㎡", "qty": 1, "ref": DG},
|
||||
]
|
||||
|
||||
|
||||
def _bills():
|
||||
frozen = _freeze(_calc())
|
||||
under = _put(frozen, "earth:1", parent="earth:4") # 1.1 규준틀 → 절토(토사) 아래
|
||||
hands = {**frozen, "rows": frozen["rows"] + HANDS}
|
||||
alone = {**frozen, "rows": frozen["rows"] + HANDS[:1]}
|
||||
mat = _put(frozen, "earth:5", parent="mat:b08:sand")
|
||||
return frozen, under, hands, alone, mat
|
||||
|
||||
|
||||
def test_자식_생긴_항목_묶음_합_전달_빠지면_다시_항목():
|
||||
plain = _calc()
|
||||
frozen, under, hands, alone, mat = _bills()
|
||||
base = _rows(plain)
|
||||
rows = _rows(_calc(under))
|
||||
e1, e4 = rows["earth:1"], rows["earth:4"]
|
||||
assert (e4["kind"], e4["auto"], e4["qty"], e4["ref"], e4["price_sum"]) == (
|
||||
"group", True, None, None, None) # fmt: skip
|
||||
assert (e1["parent"], e1["level"], e4["level"]) == ("earth:4", 4, 3)
|
||||
# 묶음 금액 = 자손 합 → 1.2 · 1 로 전달 · 제 금액은 빠짐
|
||||
assert e4["sum"] == e1["sum"] == base["earth:1"]["sum"]
|
||||
assert rows["sec:1.2"]["sum"] == e1["sum"] and rows["sec:1.1"]["sum"] == 0
|
||||
assert rows["sec:1"]["sum"] == base["sec:1"]["sum"] - base["earth:4"]["sum"]
|
||||
gaps = _calc(under)["bill"]["gaps"]
|
||||
assert not [g for g in gaps if "earth:4" in json.dumps(g, ensure_ascii=False)]
|
||||
# 자식이 빠지면(원래 부모로) = 다시 항목 · 제 수량 · 단가 · 표 그대로
|
||||
assert _calc(_put(under, "earth:1", parent="sec:1.1"))["bill"] == plain["bill"]
|
||||
# 새 항목(수량 2 · 대가3) — 자식이 생기면 묶음(자식 합) · 빠지면 제 값 다시
|
||||
rows = _rows(_calc(hands))
|
||||
assert (rows["hand:a"]["kind"], rows["hand:a"]["sum"], rows["hand:c"]["level"]) == (
|
||||
"group", 1230, 3) # fmt: skip
|
||||
a = _rows(_calc(alone))["hand:a"]
|
||||
assert (a["kind"], a["qty"], a["sum"]) == ("item", 2, 2460)
|
||||
# 저장 다듬기 = 자식 있는 새 항목도 제 수량 · 연결 지킴(묶음은 셈이 정함)
|
||||
kept = {r["key"]: r for r in store.clean_bill(hands)["rows"]}["hand:a"]
|
||||
assert (kept.get("kind"), kept["qty"], kept["ref"]) == (None, 2, DG)
|
||||
# 자재 줄 = 부모 못 됨 → 기본 부모
|
||||
rows = _rows(_calc(mat))
|
||||
assert rows["earth:5"]["parent"] == "sec:1.2" and rows["mat:b08:sand"]["kind"] == "item"
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
calc = _ts(["Calc_Base", "Calc_Bill"])
|
||||
users = [{**BASE, "bill": b} for b in _bills()]
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
|
||||
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
|
||||
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));"
|
||||
)
|
||||
got = json.loads(_node_calc(calc + body))
|
||||
assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users]
|
||||
Reference in New Issue
Block a user