From 2be44a7d2995c19718f393700276412a4cabd11b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:35:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=C2=B7=20?= =?UTF-8?q?=EC=A4=84=20=EB=81=8C=EA=B8=B0(=EC=9E=90=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8)=20=C2=B7=20=EC=9E=90=EC=86=90=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=C2=B7=20=EB=AC=B6=EC=9D=8C/=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20(204-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고른 줄 방향키 → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로 · Alt+←→ 안 받음) - 줄 끌기 = 위아래 자리 · 좌우 레벨 · 미리 보기 선 · 못 놓는 자리 빨간 점선(공용 표 안 고침) - 자식이 생긴 항목 = 저절로 묶음(Calc_Bill 짝 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음) - 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) · 자재 · 조달수수료 줄 = 부모 못 됨 - 시험 test_204_1 새로 · 184_3 · 166_2 · 200_2 메뉴 · 키 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B09_Estimation/B09_Estimation_Calc_Bill.py | 15 +- B09_Estimation/B09_Estimation_Calc_Bill.ts | 15 +- B09_Estimation/B09_Estimation_UI_Page.css | 13 + B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 + .../B09_Estimation_UI_Tabs_Bill_Drag.ts | 127 +++++++++ .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 39 ++- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 17 +- resources/tester/test_166_2_b09_bill_hand.py | 3 +- resources/tester/test_184_3_b09_bill_menu.py | 7 +- .../tester/test_200_2_b09_bill_level_bar.py | 6 +- resources/tester/test_204_1_b09_bill_drag.py | 242 ++++++++++++++++++ 11 files changed, 468 insertions(+), 18 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts create mode 100644 resources/tester/test_204_1_b09_bill_drag.py diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index c9f4db36e..30b69f9c4 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 249a23463..3a5702b8e 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -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(); @@ -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; } diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index f5fbd8357..7c1d0903d 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -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); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index c14b71c14..b372be207 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -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); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts new file mode 100644 index 000000000..786ccc285 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts @@ -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("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(`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); + }); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 105b3ff80..d8ea35242 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -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; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index ca070d200..6d9cb5125 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -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(); diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index 9b2c75113..45a41bbca 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -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:"] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index c9062f9a3..653d8a64d 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -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 # 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음 diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index b2e0936b5..92acb2d64 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -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_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌(): diff --git a/resources/tester/test_204_1_b09_bill_drag.py b/resources/tester/test_204_1_b09_bill_drag.py new file mode 100644 index 000000000..20367728b --- /dev/null +++ b/resources/tester/test_204_1_b09_bill_drag.py @@ -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] From 9ba94e46c3a67d1566fd5908fc5b51edc8ec73cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:35:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=9A=94=EA=B5=AC=201010=5F2=2061=EB=B2=88?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=20=EC=A4=84(=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index c7e8c1d58..6a79287dd 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -81,3 +81,7 @@ 58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만) 59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동 60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동 +61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함. +62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만) +63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현 +64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임)