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

This commit is contained in:
2026-10-10 14:40:31 +09:00
48 changed files with 1536 additions and 367 deletions
+11 -4
View File
@@ -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
+12 -3
View File
@@ -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;
}
+18
View File
@@ -15,6 +15,7 @@
from __future__ import annotations
import json
from datetime import datetime
from decimal import Decimal
from typing import Any
@@ -197,6 +198,23 @@ def refresh_book(keys: list[str]) -> dict[str, Any]:
}
def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
"""203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None)."""
if book is None:
return "단가 아직 안 굳힘"
fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌
rows = book.get("rows") or {}
new = sum(key not in rows for key in fresh["rows"])
changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items())
parts = [
f"새 단가 줄 {new}" if new else "",
f"M01 에서 바뀐 단가 {changed}" if changed else "",
"운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "",
"환율 바뀜" if book.get("fx") != fresh["fx"] else "",
]
return " · ".join(p for p in parts if p) or None
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
if book is None:
+2
View File
@@ -130,6 +130,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"inputs": inputs,
"user": user,
"book": {"made": book.get("made")} if book else None,
# 203-1 [업데이트] 배지 까닭(지금 M01 과 다른 것 한 줄 · 같으면 None)
"stale": await asyncio.to_thread(prices.book_stale, book, keys),
"sheets": sheets,
"rate_candidates": rate_candidates(),
"rate_table": rate_table(),
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* B09_Estimation_UI_Check.ts
* B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용).
*
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
* 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
* ========================================================================== */
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import type { Row } from "./B09_Estimation_Calc_Base";
import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
export interface CheckItem {
link: Link;
tab: string;
name: string;
why: string;
}
/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */
export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } {
const gaps: Row[] = sheets.gaps || [];
const items = gaps.map((g) => ({
link: { tab: g.tab as TabId, key: g.key },
tab: tabName(g.tab),
name: String(g.name ?? ""),
why: String(g.why ?? ""),
}));
const notes: string[] = [...(payload.notes || [])];
const missing: string[] = payload.inputs?.missing || [];
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
return { items, notes };
}
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
export function refreshTip(payload: Row | null): string {
const made = payload?.book?.made;
const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘";
const stale = payload?.stale;
return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
}
export interface CheckPanel {
/** 묶음 안 연결 점검 자리 */
list: HTMLElement;
draw: (payload: Row, sheets: Row) => void;
}
export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel {
let payload: Row | null = null;
let items: CheckItem[] = [];
attachHoverInfo(refresh, () => refreshTip(payload));
const list = document.createElement("div");
list.className = "b09-check";
attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
const draw = (next: Row, sheets: Row): void => {
payload = next;
refresh.classList.toggle("is-pending", Boolean(next.stale));
const made = checkItems(sheets, next);
items = made.items;
const head = document.createElement("p");
head.className = "b09-check__head";
head.textContent = `연결 없음 ${items.length}곳`;
const rows = document.createElement("div");
rows.className = "ui-sidebar-list";
rows.append(
...items.map((item, i) => {
const row = document.createElement("button");
row.type = "button";
row.className = "ui-sidebar-row";
row.dataset.i = String(i);
const name = document.createElement("span");
name.textContent = item.name;
const tab = document.createElement("span");
tab.className = "b09-check__tab";
tab.textContent = item.tab;
row.append(name, tab);
row.addEventListener("click", () => goto(item.link));
return row;
}),
);
const notes = made.notes.map((text) => {
const p = document.createElement("p");
p.className = "b09-check__note";
p.textContent = text;
return p;
});
list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes);
};
return { list, draw };
}
+58 -95
View File
@@ -36,115 +36,47 @@
overflow-wrap: anywhere;
}
/* 171-3 묶음 탭 머리 — 안내 글 + 보기 단추 */
.b09-estimation__bar {
/* 202-1 보조 탭 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 보조 줄에서도 보임 · 단추 · 글 한 줄 */
.b09-estimation__tools {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex: none;
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
/* 185-2 보기 단추 글 한 줄 */
.b09-estimation__bar .ui-btn {
.b09-estimation__tools .ui-btn,
.b09-estimation__tools .ui-group-cards__label {
flex: none;
white-space: nowrap;
}
/* 200-1 묶음 탭 도구 줄 — 안내 글 = 남는 자리 한 줄 · 넘치면 줄임표(전문 = 공용 호버) */
.b09-estimation__bar {
flex-wrap: nowrap;
align-items: center;
/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */
.b09-estimation .ui-data-table + .ui-general-block__empty,
.b09-estimation .ui-group-cards + .ui-general-block__empty {
margin: var(--spacing-8) 0 0;
}
.b09-estimation__line-hint {
flex: 1 1 0;
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-caption);
}
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
font-size: var(--text-body);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
font-size: var(--text-body-sm);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
font-size: 13px;
}
/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */
.b09-estimation .ui-data-table__table tbody tr.is-group td {
font-weight: var(--font-weight-bold);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
padding-left: calc(var(--spacing-8) + 2em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
padding-left: calc(var(--spacing-8) + 3em);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
padding-left: calc(var(--spacing-8) + 4em);
}
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
}
/* 200-2 설계내역서 레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸 1em) · 글자 크기 한 벌(굵기 = 묶음 · 제목) · 글 칸 왼쪽 맞춤 */
.b09-estimation [data-b09-table="bill"] tbody tr {
--b09-lv: 0;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv2 {
--b09-lv: 1;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv3 {
--b09-lv: 2;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv4 {
--b09-lv: 3;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv5 {
--b09-lv: 4;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv6 {
--b09-lv: 5;
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td {
font-size: inherit;
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td:first-child {
padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td.is-edit:first-child {
padding-left: 0;
}
.b09-estimation [data-b09-table="bill"] tbody td:first-child .ui-data-table__input {
padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);
}
/* 200-2 설계내역서 글 칸 왼쪽 맞춤(수량만 오른쪽) */
.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) {
text-align: left;
}
@@ -192,20 +124,51 @@
font-weight: var(--font-weight-bold);
}
/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */
.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child {
padding-left: var(--spacing-8);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
padding-left: calc(var(--spacing-8) + 2em);
}
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
.b09-estimation .ui-data-table__table tbody td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
}
/* 203-1 좌측 「데이터 점검」 — [업데이트] 필요 = 단추 위 점 배지(B05 [3D 업데이트] 꼴) */
.b09-options .ui-btn.is-pending {
position: relative;
border-color: var(--color-warning);
}
.b09-options .ui-btn.is-pending::after {
content: "";
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-warning);
}
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
.b09-check__head,
.b09-check__note {
margin: var(--spacing-8) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b09-check__tab {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 줄 끌기(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);
}
+28 -20
View File
@@ -4,7 +4,7 @@
*
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
* ========================================================================== */
@@ -20,7 +20,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
@@ -31,9 +31,9 @@ import { computeEstimation } from "./B09_Estimation_Calc_Bill";
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
import { computeTotal } from "./B09_Estimation_Calc_Total";
import { createTabBars } from "./B09_Estimation_UI_TabBar";
import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
import {
buildCheck,
rowIndex,
TABS,
type Link,
@@ -70,7 +70,6 @@ function L(key: keyof typeof ui_locales): string {
const LATER: Record<string, string> = {};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
check: buildCheck,
dg_list: buildDaegaList,
dg: buildDaegaBlocks,
bill: buildBill,
@@ -152,7 +151,7 @@ export function renderB09Estimation(root: HTMLElement): void {
let payload: Row | null = null;
let user: Row = {};
let sheets: Row = {};
let current: TabId = "check";
let current: TabId = TABS[0][0];
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
const bars = createTabBars((tab) => show(tab));
@@ -163,11 +162,10 @@ export function renderB09Estimation(root: HTMLElement): void {
body.className = "b09-estimation";
body.append(bars.top, bars.sub, panel);
const made = document.createElement("p");
made.className = "b09-estimation__hint";
const recompute = (): void => {
if (payload) sheets = computeSheets(payload, user);
if (!payload) return;
sheets = computeSheets(payload, user);
check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지
};
const setUser = (next: Row): void => {
@@ -185,6 +183,7 @@ export function renderB09Estimation(root: HTMLElement): void {
const show = (tab: TabId, focus?: string): void => {
current = tab;
bars.draw(tab);
bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1)
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
const build = BUILDERS[tab];
@@ -210,6 +209,8 @@ export function renderB09Estimation(root: HTMLElement): void {
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
cellClass: spec.cellClass,
level: spec.level,
mergeSame: spec.mergeSame,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
@@ -223,7 +224,7 @@ export function renderB09Estimation(root: HTMLElement): void {
if (spec.choices)
made.root.addEventListener("click", (event) => {
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
const column = cell ? spec.columns[cell.cellIndex] : undefined;
const column = cell ? spec.columns[cellColumn(cell)] : undefined;
const items =
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
if (!cell || !items?.length) return;
@@ -235,7 +236,7 @@ export function renderB09Estimation(root: HTMLElement): void {
attachHoverInfo(
made.root,
(cell) => {
const column = spec.columns[(cell as HTMLTableCellElement).cellIndex];
const column = spec.columns[cellColumn(cell as HTMLTableCellElement)];
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
},
"tr[data-i] td",
@@ -247,14 +248,23 @@ export function renderB09Estimation(root: HTMLElement): void {
if (isGroupTab(tab)) {
const redraw = (): void => show(tab);
return void panel.replaceChildren(
...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }),
...renderGroupPanel({
tab,
spec,
hint,
focus,
goto,
makeTable,
redraw,
setTools: bars.setTools,
}),
);
}
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
const level = bill ? levelBar(table.root, bill) : null;
panel.replaceChildren(...(hint ? [hint] : []), ...(level ? [level] : []), table.root);
panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []));
if (focus !== undefined) {
const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
@@ -266,9 +276,6 @@ export function renderB09Estimation(root: HTMLElement): void {
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
user = draft ?? next.user ?? {};
recompute();
made.textContent = next.book?.made
? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로`
: "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘";
show(current);
};
@@ -297,12 +304,13 @@ export function renderB09Estimation(root: HTMLElement): void {
});
save.dataset.b09Save = "";
const refresh = createButton({
label: "단가 갱신",
label: "업데이트",
variant: "ghost",
onClick: () =>
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
});
refresh.dataset.b09Refresh = "";
const check = createCheckPanel(refresh, (link) => show(link.tab, link.key));
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
const reset = createButton({
label: "내역 초기화",
@@ -322,8 +330,8 @@ export function renderB09Estimation(root: HTMLElement): void {
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = "원가 단가";
section.append(heading, made, refresh);
heading.textContent = "데이터 점검";
section.append(heading, refresh, check.list);
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(save, reset);
+10 -3
View File
@@ -5,6 +5,7 @@
* 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움)
* 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭
* 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐
* 보기 단추 · 가로 열 수(202-1) = 아래 줄 오른쪽 맞춤 자리(`setTools`) · 빈 줄에서도 보임
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -15,6 +16,8 @@ export interface TabBars {
sub: HTMLElement;
/** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */
draw: (tab: TabId) => void;
/** 아래 줄 오른쪽 도구(박스보기 · 가로 열 수) — 빈 목록 = 비움 */
setTools: (tools: HTMLElement[]) => void;
}
function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) {
@@ -35,6 +38,8 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
top.setAttribute("role", "tablist");
const sub = document.createElement("div");
sub.className = "b09-estimation__tabs";
const tools = document.createElement("div");
tools.className = "b09-estimation__tools";
const lastSeen = new Map<TabId, TabId>();
const draw = (tab: TabId): void => {
@@ -53,11 +58,11 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
const single = picked.length === 1;
sub.classList.toggle("b09-estimation__tabs--blank", single);
sub.setAttribute("role", single ? "presentation" : "tablist");
sub.setAttribute("aria-hidden", String(single));
if (single) {
const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" });
ghost.tabIndex = -1;
return void sub.replaceChildren(ghost);
ghost.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음
return void sub.replaceChildren(ghost, tools);
}
sub.replaceChildren(
...picked.map((id) => {
@@ -65,7 +70,9 @@ export function createTabBars(onPick: (tab: TabId) => void): TabBars {
button.dataset.b09Tab = id;
return button;
}),
tools,
);
};
return { top, sub, draw };
const setTools = (items: HTMLElement[]): void => tools.replaceChildren(...items);
return { top, sub, draw, setTools };
}
+14 -39
View File
@@ -2,17 +2,17 @@
* B09_Estimation_UI_Tabs.ts
* B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례).
*
* 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
* 탭 = 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
* 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`)
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴만
* 연결 점검(옛 첫 탭) = 좌측 「데이터 점검」 묶음(203-1 `…_UI_Check`)
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
export type TabId =
| "check"
| "cost"
| "total"
| "bill"
@@ -34,7 +34,6 @@ export type TabId =
/** 엑셀 탭 차례 — [id, 탭 이름] */
export const TABS: Array<[TabId, string]> = [
["check", "연결 점검"],
["cost", "공사원가계산서"],
["total", "총괄설계내역서"],
["bill", "설계내역서"],
@@ -59,7 +58,6 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1]
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
export const TAB_GROUPS: Array<[string, TabId[]]> = [
["연결 점검", ["check"]],
["공사원가계산서", ["cost"]],
["총괄설계내역서", ["total"]],
["설계내역서", ["bill"]],
@@ -112,10 +110,20 @@ export interface TabTable {
cellClass?: (index: number, key: string) => string | null | undefined;
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
/** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */
level?: DataTableOptions["level"];
/** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */
mergeSame?: string[];
}
export type TabBuild = (ctx: TabContext) => TabTable | string;
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
key,
of: (i) => (top(i) ? 1 : 2),
});
/* ── 열 꼴 ────────────────────────────────────────────────────────────── */
export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({
@@ -156,36 +164,3 @@ export function parseNumber(value: string): number | null | undefined {
export function rowIndex(rows: DataRow[], key: string): number {
return rows.findIndex((row) => row.key === key);
}
/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */
export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => {
const gaps: Row[] = sheets.gaps || [];
const rows: DataRow[] = gaps.map((g, i) => ({
no: i + 1,
key: `${g.tab}|${g.key}|${i}`,
tab: tabName(g.tab),
name: g.name,
why: g.why,
}));
const notes: string[] = [...(payload.notes || [])];
const missing: string[] = payload.inputs?.missing || [];
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
return {
columns: [
qty("no", "번호", 0),
text("tab", "탭", 150),
text("name", "명칭", 260),
text("why", "까닭", 320),
],
rows,
frozen: 1,
hint:
`연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` +
(notes.length ? ` · ${notes.join(" · ")}` : ""),
link: (i) => {
const g = gaps[i];
return g ? { tab: g.tab as TabId, key: g.key } : null;
},
};
};
@@ -19,6 +19,7 @@ import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
import {
blockLevel,
money,
parseNumber,
qty,
@@ -51,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.labor || [],
"단가(원/인)",
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`,
);
export const buildExpense: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.expense || [],
"기준가(천원)",
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`,
);
function madeText(payload: Row): string {
@@ -102,6 +103,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
],
rows,
frozen: 2,
mergeSame: ["part"],
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
link: (i) => {
const row = rows[i];
@@ -344,7 +346,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
rows.push({
key: `${b.key}|${ln.name}`,
name: ` ${ln.name}`,
name: ln.name,
spec: ln.spec,
qty: ln.qty,
unit: ln.unit,
@@ -361,7 +363,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
}
rows.push({
key: `${b.key}|합계`,
name: " 합 계",
name: "합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
@@ -388,5 +390,6 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
frozen: 1,
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
link: (i) => links[i] ?? null,
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
};
};
@@ -7,7 +7,7 @@
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* ========================================================================== */
@@ -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,
@@ -93,9 +94,11 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const r = rows[i];
if (!r) return null;
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null;
},
level: { key: "name", of: (i) => rows[i]?.level as number | undefined },
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();
+10 -10
View File
@@ -7,7 +7,8 @@
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* 도구 줄(200-1) = 안내 글 + 가로 열 수 + 보기 단추 한 줄 · 안내 글 길면 줄임표 + 공용 호버로 전문
* 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래
* 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1)
* ========================================================================== */
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
@@ -176,7 +177,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
/* ── 그리기 ───────────────────────────────────────────────────────────── */
/** 안내 글 = 도구 줄 한 칸(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
function lineHint(hint: HTMLElement | null): HTMLElement | null {
if (!hint) return null;
hint.classList.add("b09-estimation__line-hint");
@@ -187,7 +188,7 @@ function lineHint(hint: HTMLElement | null): HTMLElement | null {
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
const chosen = new Map<TabId, GroupView>();
/** 묶음 탭 — 카드 = 카드 판(안내 · 가로 열 수 · 보기 단추 한 줄) · 표 = 머리(안내 + 보기 단추 한 줄) + 표 */
/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */
export function renderGroupPanel(o: {
tab: TabId;
spec: TabTable;
@@ -196,6 +197,7 @@ export function renderGroupPanel(o: {
goto: (link: Link | null) => void;
makeTable: (rowClass?: (index: number) => string | null) => DataTable;
redraw: () => void;
setTools: (tools: HTMLElement[]) => void;
}): HTMLElement[] {
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
const lead = lineHint(o.hint);
@@ -221,17 +223,15 @@ export function renderGroupPanel(o: {
focus: at,
money: numberFormat(o.spec, "sum"),
qty: numberFormat(o.spec, "qty"),
lead, // 200-1 안내 글도 같은 줄
actions: [toggle], // 185-2 가로 열 수 단추와 한 줄
actions: [toggle],
placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽
});
cards.dataset.b09Table = o.tab;
return [cards];
return [cards, ...(lead ? [lead] : [])];
}
const bar = document.createElement("div");
bar.className = "b09-estimation__bar";
bar.append(...(lead ? [lead] : []), toggle);
o.setTools([toggle]);
const range = pickRange(o.spec.rows, o.focus);
const table = o.makeTable((i) => pickClass(range, i));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
return [bar, table.root];
return [table.root, ...(lead ? [lead] : [])];
}
@@ -12,6 +12,7 @@
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
blockLevel,
money,
qty,
text,
@@ -25,7 +26,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun";
const EMPTY =
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
const CHILD = " ㄴ ";
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김";
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
@@ -122,7 +123,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
titles.add(rows.length);
rows.push({
key: `${b.id}|합계`,
name: " 합 계",
name: "합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
@@ -154,6 +155,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
level: blockLevel("name", (i) => titles.has(i)),
tip: (i: number, key: string) =>
key === "spec"
? raw(rows[i]?.spec, rows[i]?.spec_raw)
@@ -1,7 +1,7 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Sangeun.ts
* B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun`
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈).
*
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
* 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 ·
@@ -13,6 +13,7 @@
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
blockLevel,
money,
qty,
text,
@@ -99,7 +100,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
null,
true,
);
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: line }));
(b.lines as Row[]).forEach((ln, n) => {
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
const kind =
@@ -115,8 +116,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
push(
{
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
kind: k === 0 ? kind : "",
text: ` ${texts[k] ?? fallback}`,
kind, // 같은 공종 줄 = 세로 병합(201-1)
text: texts[k] ?? fallback,
sum: PARTS.some((p) => at(p) === null)
? null
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
@@ -162,6 +163,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
notes(sheets),
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
level: blockLevel("text", (i) => titles.has(i)),
mergeSame: ["kind"],
tip: (i: number, key: string) =>
key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null,
};
@@ -24,7 +24,7 @@ const HINT =
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
const hint = (payload: Row, extra = ""): string => {
const n = (payload.summary || {}).unlinked || 0;
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`;
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`;
};
/** 줄이 없을 때 표 위 맨 앞 안내 */
+12 -11
View File
@@ -8,8 +8,8 @@
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 ·
* 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title)
* 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) →
* 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title)
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
* ========================================================================== */
@@ -77,13 +77,12 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
ctx.setUser({ ...ctx.user, estimate });
}
/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */
const levelClass = (r: DataRow | undefined): string | null =>
r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null;
const indentClass =
(first: string, name: string) =>
(_i: number, key: string): string | null =>
key === name ? "is-indent" : key === first ? "is-flat" : null;
/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */
const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null);
const nameLevel = (rows: DataRow[]) => ({
key: "name",
of: (i: number) => rows[i]?.level as number | undefined,
});
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
@@ -210,7 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
rows,
frozen: 2,
rowClass: (i) => levelClass(rows[i]),
cellClass: indentClass("part", "name"),
level: nameLevel(rows),
mergeSame: ["part"],
hint:
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
@@ -290,7 +290,8 @@ export const buildCost: TabBuild = (ctx): TabTable => {
rows,
frozen: 3,
rowClass: (i) => levelClass(rows[i]),
cellClass: indentClass("group", "name"),
level: nameLevel(rows),
mergeSame: ["group", "part"],
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),