Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -569,7 +569,7 @@
|
||||
}
|
||||
|
||||
/* 상자 안 작은 단추 템플릿(54-41) — 높이 = 숫자 칸 [-][+](`--b05-control-h`) · 글 · 안 여백도 그 높이에 ·
|
||||
* 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 제외 */
|
||||
* 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 아래 205-1 */
|
||||
.b05-structure__group-body button:not(.b05-structure__step),
|
||||
.b05-structure__group > legend > button {
|
||||
box-sizing: border-box;
|
||||
@@ -580,6 +580,24 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) ·
|
||||
* 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */
|
||||
.b05-structure-section .b05-route__irregular-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__irregular-actions > button {
|
||||
box-sizing: border-box;
|
||||
height: var(--b05-control-h);
|
||||
min-height: 0;
|
||||
padding-block: 0;
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */
|
||||
.b05-structure__widening[hidden] {
|
||||
display: none;
|
||||
|
||||
@@ -152,13 +152,13 @@ export function note(text: string): HTMLElement {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */
|
||||
/** 표 한 장 — 공용 컨테이너(카드) 안에 공용 표 · 아래 줄 · 맨 아래 안내 · 경고 줄(202-1) */
|
||||
export function tableCard(
|
||||
info: HTMLElement[],
|
||||
table: HTMLElement,
|
||||
after: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
return createCard({ body: [...info, table, ...after] });
|
||||
return createCard({ body: [table, ...after, ...info] });
|
||||
}
|
||||
|
||||
function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow {
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
|
||||
note: row.note || " ",
|
||||
})),
|
||||
frozen: 1,
|
||||
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
|
||||
caption: "토공집계표",
|
||||
fillViewport: true,
|
||||
});
|
||||
|
||||
@@ -109,6 +109,8 @@ interface Table {
|
||||
editable: (index: number, key: string) => boolean;
|
||||
/** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */
|
||||
target: (index: number, key: string) => [Record<string, Cells> | Cells, string, string | null];
|
||||
/** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
@@ -218,10 +220,10 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
|
||||
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows, total } = s.concrete;
|
||||
const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침
|
||||
const rowsOut: DataRow[] = keep.map((at, i) => {
|
||||
const rowsOut: DataRow[] = keep.map((at) => {
|
||||
const row = rows[at];
|
||||
const out: DataRow = {
|
||||
name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name,
|
||||
name: row.name, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
};
|
||||
@@ -256,6 +258,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
rows: rowsOut,
|
||||
total: totalRow,
|
||||
frozen: 2,
|
||||
mergeSame: ["name"],
|
||||
editable: (_i, key) => key.startsWith("per:"),
|
||||
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
|
||||
};
|
||||
@@ -275,10 +278,10 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
numberColumn({ key: "amount", label: "합계", width: 96 }),
|
||||
{ key: "note", label: "비고", width: 96 },
|
||||
],
|
||||
rows: keep.map((at, i) => {
|
||||
rows: keep.map((at) => {
|
||||
const row = rows[at];
|
||||
return {
|
||||
group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group,
|
||||
group: row.group, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
@@ -295,6 +298,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
}),
|
||||
total: null,
|
||||
frozen: 2,
|
||||
mergeSame: ["group"],
|
||||
editable: (i, key) =>
|
||||
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
|
||||
target: (i) => [o.surcharge, rows[keep[i]].key, null],
|
||||
@@ -356,7 +360,7 @@ export async function renderMaterialSheet(
|
||||
const concreteNote = note(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
|
||||
concreteNote.dataset.b08MaterialConcreteWhy = "";
|
||||
if (sheet === "concrete") notes.append(concreteNote);
|
||||
toolbar.append(notes, save);
|
||||
toolbar.append(save);
|
||||
|
||||
let built = BUILD[sheet](sheets, overrides);
|
||||
const table = createDataTable({
|
||||
@@ -364,6 +368,7 @@ export async function renderMaterialSheet(
|
||||
rows: built.rows,
|
||||
total: built.total,
|
||||
frozen: built.frozen,
|
||||
mergeSame: built.mergeSame,
|
||||
empty: "",
|
||||
caption: BASIS,
|
||||
fillViewport: true,
|
||||
@@ -395,7 +400,7 @@ export async function renderMaterialSheet(
|
||||
concreteNote.hidden = !concreteNote.textContent;
|
||||
};
|
||||
redraw();
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root, excel] }));
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root, excel, notes] })); // 202-1 안내 · 경고 = 맨 아래
|
||||
onOptionsChange(host, () => {
|
||||
sheets = calc();
|
||||
redraw();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
.b08-material__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-16);
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -281,8 +281,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
)
|
||||
: note(L("B08_Quantity_Summary_NoStation"));
|
||||
let table = draw();
|
||||
parts.push(table);
|
||||
host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3)
|
||||
host.replaceChildren(createCard({ body: [table, ...parts] })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3) · 안내 · 경고 = 표 아래(202-1)
|
||||
onOptionsChange(host, () => {
|
||||
const next = draw();
|
||||
table.replaceWith(next);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} — 전개 안 함(좌측 데이터 점검)` : ""}`;
|
||||
};
|
||||
|
||||
/** 줄이 없을 때 표 위 맨 앞 안내 */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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형 단추 안 보임)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/* 202-1 — 표 칸 아래로 간 안내 줄 몫이 창 끝까지 셈(`ui_template_data_table.ts` fillViewport)에 들어가는지 단위검증 헬퍼.
|
||||
* 카드 본문 = [표 칸][아래 줄][안내 줄 · 안내 줄 …](B08 `tableCard` 꼴) 가짜 DOM 에서 돌리고 JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
|
||||
const win = { innerHeight: 900, scrollY: 0, addEventListener() {}, removeEventListener() {} };
|
||||
const observers = [];
|
||||
class RO {
|
||||
constructor(fn) {
|
||||
this.fn = fn;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
const el = (name, css = {}, height = 0) => ({
|
||||
name,
|
||||
css,
|
||||
height,
|
||||
top: 0,
|
||||
parentElement: null,
|
||||
nextElementSibling: null,
|
||||
isConnected: true,
|
||||
style: {},
|
||||
getBoundingClientRect() {
|
||||
return { top: this.top, height: this.height };
|
||||
},
|
||||
});
|
||||
function link(parent, ...kids) {
|
||||
kids.forEach((k, i) => {
|
||||
k.parentElement = parent;
|
||||
k.nextElementSibling = kids[i + 1] ?? null;
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
const sty = (n) => ({
|
||||
rowGap: n.css.rowGap ?? "normal",
|
||||
paddingBottom: n.css.paddingBottom ?? "0px",
|
||||
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
|
||||
marginBottom: n.css.marginBottom ?? "0px",
|
||||
});
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)(
|
||||
box.exports,
|
||||
box,
|
||||
(p) => (p.endsWith(".css") ? {} : require(p)),
|
||||
win,
|
||||
{ body: null },
|
||||
sty,
|
||||
RO,
|
||||
);
|
||||
const m = box.exports;
|
||||
|
||||
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40]
|
||||
const body = el("body", { paddingBottom: "8px" });
|
||||
const card = el("card", { paddingBottom: "12px", rowGap: "8px" });
|
||||
const root = el("root");
|
||||
const avg = el("avg", {}, 20);
|
||||
const info = el("info", {}, 30);
|
||||
const hint = el("hint", {}, 40);
|
||||
link(body, card);
|
||||
link(card, root, avg, info, hint);
|
||||
root.top = 200;
|
||||
|
||||
m.fillViewport(root);
|
||||
observers.forEach((o) => o.fn());
|
||||
const out = { first: parseFloat(root.style.maxHeight) };
|
||||
out.below = m.spaceBelow(root);
|
||||
out.belowExpected = 3 * 8 + 20 + 30 + 40 + 12 + 8; // 줄 사이 8 셋 · 아래 줄 · 안내 둘 · 카드 · 본문 여백
|
||||
out.bottom = root.top + out.first + out.below; // 문서 맨 아래 = 창 아래 끝(페이지 스크롤 0)
|
||||
out.viewport = win.innerHeight;
|
||||
|
||||
// 안내 줄이 두 줄로 늘어남 → 표 칸이 그만큼 줄어듦
|
||||
hint.height = 60;
|
||||
observers.forEach((o) => o.fn());
|
||||
out.afterHintGrow = parseFloat(root.style.maxHeight);
|
||||
out.firstMinusGrow = out.first - 20;
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -27,5 +27,7 @@ def test_자재_셋_값_없는_줄_숨김():
|
||||
|
||||
|
||||
def test_표_넷_공용_카드_안():
|
||||
assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4)
|
||||
assert "createCard({ body: parts })" in SUMMARY
|
||||
assert (
|
||||
"createCard({ body: [toolbar, table.root, excel, notes] })" in MATERIAL
|
||||
) # + 엑셀 알림(160-4) · 안내 = 아래(202-1)
|
||||
assert "createCard({ body: [table, ...parts] })" in SUMMARY
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기.
|
||||
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김)
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 좌측 「데이터 점검」 묶음 203-1 · INITIAL 안 옮김)
|
||||
- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함
|
||||
- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표
|
||||
· B08 자재만 줄 · 출처 빈칸
|
||||
@@ -45,8 +45,7 @@ def test_탭_차례가_엑셀과_같다():
|
||||
source.index("export const TABS") : source.index("];", source.index("export const TABS"))
|
||||
]
|
||||
names = re.findall(r'\["\w+", "([^"]+)"\]', block)
|
||||
assert names[0] == "연결 점검"
|
||||
assert names[1:] == EXCEL_TABS
|
||||
assert names == EXCEL_TABS # 203-1 「연결 점검」 탭 = 좌측 묶음으로 옮김
|
||||
|
||||
|
||||
def test_길_셋():
|
||||
|
||||
@@ -52,16 +52,18 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ():
|
||||
out = _node(
|
||||
f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});"
|
||||
"\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name),"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null),"
|
||||
" lv: [t.level.key, ...t.rows.map((_, i) => t.level.of(i))] }));"
|
||||
)
|
||||
assert out["names"] == [
|
||||
"제 1 호표 찰쌓기",
|
||||
" ㄴ 건설표준품셈 7-1-2",
|
||||
" ㄴ 석공",
|
||||
" ㄴ 뒷채움",
|
||||
" 합 계",
|
||||
]
|
||||
"ㄴ 건설표준품셈 7-1-2",
|
||||
"ㄴ 석공",
|
||||
"ㄴ 뒷채움",
|
||||
"합 계",
|
||||
] # 201-1 들여쓰기 = 공용 표 level(앞 빈칸 없앰)
|
||||
assert out["cls"] == ["is-title", None, None, None, "is-title"]
|
||||
assert out["lv"] == ["name", 1, 2, 2, 2, 1]
|
||||
|
||||
|
||||
SG = {
|
||||
@@ -92,12 +94,12 @@ def test_155_5_단가산출근거_엑셀_꼴_칸():
|
||||
rows = out["rows"]
|
||||
assert [(r.get("kind"), r["text"]) for r in rows] == [
|
||||
("제 1 호표", "뒷채움 / m3"),
|
||||
(None, " 산림품셈 13-3-2 적용"),
|
||||
(None, " 규격 : 150 mm"),
|
||||
("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"),
|
||||
("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
(None, "산림품셈 13-3-2 적용"),
|
||||
(None, "규격 : 150 mm"),
|
||||
("노무", "보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", "굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("중기", "굴착기 재료비 : 7,695 * 0.07 = 538.6"), # 201-1 같은 공종 = 세로 병합
|
||||
("중기", "굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"),
|
||||
]
|
||||
assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8]
|
||||
|
||||
@@ -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:"]
|
||||
|
||||
@@ -142,21 +142,21 @@ def test_화면_보기_바꿈_누름_이동():
|
||||
out = run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
"\nconst gone = []; let redraws = 0; const focused = [];"
|
||||
"\nconst gone = []; let redraws = 0; const focused = []; const calls = [];"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l),"
|
||||
" redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table'),"
|
||||
" redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table'),"
|
||||
" focusRow: (i) => focused.push(i) }) };"
|
||||
"\nconst [view] = renderGroupPanel(o);"
|
||||
"\nconst btn = view.querySelector('[data-b09-group-view]');"
|
||||
"\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);"
|
||||
"\nconst label = btn.textContent;"
|
||||
"\nview.querySelector('[data-at=\"2\"]').click();"
|
||||
"\nview.querySelector('[data-at=\"0\"]').click();"
|
||||
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
|
||||
"\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');"
|
||||
"\nbtn.click();"
|
||||
"\nconst [bar2, view2] = renderGroupPanel(o);"
|
||||
"\nconst [view2] = renderGroupPanel(o);"
|
||||
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws,"
|
||||
" label2: bar2.children[0].textContent, tag2: view2.tagName, focused,"
|
||||
" label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused,"
|
||||
" kept: Object.fromEntries(kept) }));",
|
||||
RAW,
|
||||
)
|
||||
@@ -171,7 +171,11 @@ def test_화면_보기_바꿈_누름_이동():
|
||||
|
||||
def test_잇기():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "if (isGroupTab(tab)) {" in shell and "renderGroupPanel({ tab, spec," in shell
|
||||
assert (
|
||||
"if (isGroupTab(tab)) {" in shell
|
||||
and "renderGroupPanel({" in shell
|
||||
and "setTools: bars.setTools" in shell
|
||||
)
|
||||
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text("utf-8")
|
||||
assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state
|
||||
assert '"b09-group-cols": { bucket: "pref", scope: "global" },' in state
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
|
||||
"""
|
||||
|
||||
@@ -18,7 +18,6 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
|
||||
|
||||
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
|
||||
EXPECTED = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
@@ -31,7 +30,7 @@ EXPECTED = [
|
||||
]
|
||||
|
||||
|
||||
def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t),"
|
||||
@@ -39,11 +38,11 @@ def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
)
|
||||
assert out["groups"] == EXPECTED
|
||||
flat = [t for _, tabs in out["groups"] for t in tabs]
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 18
|
||||
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
|
||||
firsts = list(dict.fromkeys(out["of"]))
|
||||
assert firsts == [g[0] for g in EXPECTED]
|
||||
assert len(firsts) == 10
|
||||
assert len(firsts) == 9
|
||||
|
||||
|
||||
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
|
||||
@@ -55,32 +54,32 @@ const bars = createTabBars((t) => picks.push(t));
|
||||
const view = () => ({
|
||||
top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'],
|
||||
b.dataset.b09Group, b.dataset.b09Tab ?? null]),
|
||||
sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
|
||||
sub: bars.sub.children.filter((b) => !b.classList.contains('b09-estimation__tools')).map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
|
||||
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
|
||||
role: bars.sub.attrs.role,
|
||||
});
|
||||
bars.draw('dg');
|
||||
const a = view();
|
||||
bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.sub.children[0].click(); // 일위대가목록표
|
||||
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
|
||||
const b = view();
|
||||
bars.draw('sum_material');
|
||||
bars.draw('dg');
|
||||
bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.draw('cost');
|
||||
const c = view();
|
||||
bars.top.children[0].click(); // 연결 점검 = 단독 위 탭
|
||||
bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭
|
||||
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
|
||||
console.log(JSON.stringify({ a, b, c, picks }));
|
||||
""",
|
||||
)
|
||||
a, b, c = out["a"], out["b"], out["c"]
|
||||
assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [
|
||||
"연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가"
|
||||
assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [
|
||||
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출"
|
||||
] # fmt: skip
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5
|
||||
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
|
||||
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
|
||||
assert not a["blank"] and a["role"] == "tablist"
|
||||
@@ -89,11 +88,11 @@ console.log(JSON.stringify({ a, b, c, picks }));
|
||||
["자재단가대비표", "true", "compare"],
|
||||
["재료비수량금액집계표", "false", "sum_material"],
|
||||
] # fmt: skip
|
||||
assert b["top"][8][2] == "true" and b["top"][4][2] == "false"
|
||||
assert b["top"][7][2] == "true" and b["top"][3][2] == "false"
|
||||
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
|
||||
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
|
||||
assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "check"]
|
||||
assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "total"]
|
||||
|
||||
|
||||
def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙():
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_표_보기_줄_class_넘김():
|
||||
[COMMON, TABS, GROUPS],
|
||||
f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};"
|
||||
"\nlet extra = null;"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {},"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {},"
|
||||
" makeTable: (e) => { extra = e; return { root: document.createElement('table'),"
|
||||
" focusRow: () => {} }; } };"
|
||||
"\nchosen.set('dg', 'table');"
|
||||
|
||||
@@ -281,6 +281,7 @@ const out = {
|
||||
connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect),
|
||||
link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")],
|
||||
cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))),
|
||||
lv: [table.level.key, ...["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.level.of(at(k)))],
|
||||
edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")],
|
||||
};
|
||||
table.onCell(at("hand:a"), "connect");
|
||||
@@ -309,7 +310,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
|
||||
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
|
||||
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
|
||||
assert out["cls"] == [
|
||||
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
|
||||
"is-title",
|
||||
"is-group is-hand",
|
||||
"is-hand",
|
||||
None,
|
||||
] # fmt: skip 201-1 레벨 = 공용 표 level
|
||||
assert out["lv"] == ["name", 1, 3, 4, 3]
|
||||
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
|
||||
picks = [
|
||||
"일위대가 호표",
|
||||
|
||||
@@ -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
|
||||
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS.
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 1단 굵게 · 공용 표 level 옵션(201-1).
|
||||
|
||||
레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음).
|
||||
총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3.
|
||||
들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로.
|
||||
들여쓰기 = 공용 표 level { key: 명칭 칸 · of = 줄 level }(201-1 · 옛 is-lvN · is-indent · is-flat 갈음).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -20,7 +20,7 @@ SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply
|
||||
BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]);
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]);
|
||||
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
|
||||
"""
|
||||
|
||||
@@ -42,39 +42,35 @@ def _run() -> dict:
|
||||
def test_총괄_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
|
||||
assert all(rows[k] == (1, "is-lv1 is-title") for k in top)
|
||||
assert all(rows[k] == (1, "is-title") for k in top)
|
||||
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
|
||||
"supply"]: # fmt: skip
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert rows[k] == (2, None), k
|
||||
assert {lv for lv, _ in rows.values()} == {1, 2}
|
||||
|
||||
|
||||
def test_원가계산서_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
|
||||
assert rows[k] == (1, "is-lv1 is-title"), k
|
||||
assert rows[k] == (1, "is-title"), k
|
||||
for k in ["material", "labor", "expense_sum"]:
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert rows[k] == (2, None), k
|
||||
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
|
||||
assert rows[k] == (3, "is-lv3"), k
|
||||
assert rows[k] == (3, None), k
|
||||
|
||||
|
||||
def test_칸_class_명칭만_들여씀():
|
||||
def test_명칭_칸만_들여씀_공용_level():
|
||||
out = _run()
|
||||
for tab in ("total", "cost"):
|
||||
for _k, _lv, _cls, name, first, amount in out[tab]:
|
||||
assert (name, first, amount) == ("is-indent", "is-flat", None)
|
||||
for _k, lv, _cls, key, of, cell in out[tab]:
|
||||
assert (key, of, cell) == ("name", lv, None) # 칸 class(is-indent · is-flat) 없음
|
||||
|
||||
|
||||
def test_CSS_레벨_규칙():
|
||||
def test_CSS_옛_레벨_규칙_없음_공용_표로():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8")
|
||||
assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css
|
||||
common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8")
|
||||
assert re.search(
|
||||
r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css
|
||||
r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);",
|
||||
common,
|
||||
)
|
||||
for lv, em in ((2, 1), (3, 2)):
|
||||
assert re.search(
|
||||
rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);",
|
||||
css,
|
||||
)
|
||||
# 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지)
|
||||
assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""185-2 B09 묶음 탭 도구 줄 — 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
|
||||
"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄.
|
||||
|
||||
- 카드 보기 = 보기 바꿈 단추가 가로 열 수 줄(`ui-group-cards__bar`) 안 · 표 보기 = 머리 줄(`b09-estimation__bar`)
|
||||
- 단추 · 라벨 = `white-space: nowrap` · 도구 줄 = 줄바꿈 없음(`flex-wrap: nowrap`)
|
||||
- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만
|
||||
- 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -27,40 +27,43 @@ def _block(css: str, selector: str) -> str:
|
||||
|
||||
|
||||
def _panel(view: str, hint: str):
|
||||
"""고른 보기 · 안내 글로 그린 뒤 보기 바꿈 단추가 든 줄 · 그 줄의 열 수 단추."""
|
||||
"""고른 보기 · 안내 글로 그린 뒤 도구 묶음(보기 바꿈 단추 · 열 수 단추) · 판 안 머리 띠."""
|
||||
return run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
f"\nchosen.set('dg', '{view}');"
|
||||
"\nconst tools = [];"
|
||||
f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}},"
|
||||
" setTools: (t) => tools.push(...t),"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
|
||||
"\nconst out = renderGroupPanel(o);"
|
||||
"\nconst top = out.find((e) => e.all('[data-b09-group-view]').length || e.dataset.b09GroupView);"
|
||||
"\nconst btn = top.dataset.b09GroupView ? top : top.querySelector('[data-b09-group-view]');"
|
||||
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar') ?? top;"
|
||||
"\nconst btn = tools.find((e) => e.dataset.b09GroupView);"
|
||||
"\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;"
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent,"
|
||||
" bar: bar.className, cols: bar.all('[data-group-cols]').map((b) => b.dataset.groupCols) }));",
|
||||
" cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols),"
|
||||
" barBtn: bar ? bar.all('[data-b09-group-view]').length : null,"
|
||||
" barCols: bar ? bar.all('[data-group-cols]').length : null }));",
|
||||
RAW,
|
||||
)
|
||||
|
||||
|
||||
def test_카드_보기_단추가_가로_열수_줄에():
|
||||
def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에():
|
||||
out = _panel("cards", "null")
|
||||
assert out["bar"] == "ui-group-cards__bar" and out["label"] == "표로 보기"
|
||||
assert out["cols"] == ["1", "2", "3", "4"] # 같은 줄에 열 수 단추 넷
|
||||
assert out["label"] == "표로 보기"
|
||||
assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷
|
||||
assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음
|
||||
assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐
|
||||
|
||||
|
||||
def test_카드_보기_안내_글은_같은_도구_줄에():
|
||||
# 200-1 — 안내 글이 카드 위 따로 줄에서 도구 줄 안으로(상세 = test_200_1)
|
||||
def test_카드_보기_안내_글은_카드_판_아래에():
|
||||
# 202-1 — 안내 글 = 카드 판 아래(상세 = test_202_1)
|
||||
out = _panel("cards", HINT)
|
||||
assert out["n"] == 1 and out["first"] == "ui-group-cards"
|
||||
assert out["bar"] == "ui-group-cards__bar"
|
||||
assert out["n"] == 2 and out["first"] == "ui-group-cards"
|
||||
|
||||
|
||||
def test_표_보기_단추는_머리_줄():
|
||||
def test_표_보기_단추는_도구_묶음에():
|
||||
out = _panel("table", "null")
|
||||
assert out["bar"] == "b09-estimation__bar" and out["label"] == "박스보기"
|
||||
assert out["label"] == "박스보기" and out["n"] == 1
|
||||
assert out["cols"] == [] # 표 보기 = 열 수 단추 없음
|
||||
|
||||
|
||||
@@ -73,5 +76,9 @@ def test_CSS_한_줄_nowrap():
|
||||
)
|
||||
assert "white-space: nowrap" in item and "flex: none" in item
|
||||
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
assert "white-space: nowrap" in _block(page, ".b09-estimation__bar .ui-btn")
|
||||
assert "flex-wrap: nowrap" in _block(page, ".b09-estimation__tools")
|
||||
tools = _block(
|
||||
page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label"
|
||||
)
|
||||
assert "white-space: nowrap" in tools
|
||||
assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""200-1 B09 박스보기 도구 줄 한 줄 · 카드 같은 가로 줄 높이 같게.
|
||||
"""200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게.
|
||||
|
||||
- 안내 글 · 가로 열 수 단추 · 보기 단추 = 같은 줄(카드 보기 = `ui-group-cards__bar` · 표 보기 = `b09-estimation__bar`)
|
||||
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문
|
||||
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
|
||||
- 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래
|
||||
- 카드 제목 줄 · 합계 줄 바탕색(200-3)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -32,43 +32,36 @@ def _block(css: str, selector: str) -> str:
|
||||
|
||||
|
||||
def _panel(view: str, hint: str):
|
||||
"""고른 보기 · 안내 글로 그린 뒤 도구 줄 구성 · 호버 기록."""
|
||||
"""고른 보기 · 안내 글로 그린 뒤 안내 글 자리 · 호버 기록."""
|
||||
return run(
|
||||
[COMMON, TABS, f"{B09}Labor.ts", GROUPS],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
f"\nchosen.set('dg', '{view}');"
|
||||
f"\nconst hint = {hint};"
|
||||
"\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {},"
|
||||
"\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {},"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };"
|
||||
"\nconst out = renderGroupPanel(o);"
|
||||
"\nconst btn = out.flatMap((e) => (e.dataset.b09GroupView ? [e] : e.all('[data-b09-group-view]')))[0];"
|
||||
"\nconst bar = btn.closest('.ui-group-cards__bar') ?? btn.closest('.b09-estimation__bar');"
|
||||
"\nconst h = hint ? bar.all('.b09-estimation__line-hint')[0] : null;"
|
||||
"\nconst last = out[out.length - 1];"
|
||||
"\nconst inBar = hint ? out.some((e) => e.all('.ui-group-cards__bar').some((b) => b.contains(hint))) : false;"
|
||||
"\nconst call = hoverCalls[0];"
|
||||
"\nconst wide = call ? call.text({ scrollWidth: 900, clientWidth: 300, textContent: 'T' }) : null;"
|
||||
"\nconst fit = call ? call.text({ scrollWidth: 300, clientWidth: 300, textContent: 'T' }) : null;"
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, bar: bar.className,"
|
||||
" cols: bar.all('[data-group-cols]').length, hint: !!h, hintInBar: h ? bar.contains(h) : false,"
|
||||
" hintCls: h ? h.className : '', hover: hoverCalls.length, hoverIsHint: call ? call.root === h : false,"
|
||||
" wide, fit, outer: out.map((e) => e.className) }));",
|
||||
"\nconsole.log(JSON.stringify({ n: out.length, hintLast: !!hint && last === hint, inBar,"
|
||||
" hintCls: hint ? hint.className : '', hover: hoverCalls.length,"
|
||||
" hoverIsHint: call ? call.root === hint : false, wide, fit, first: out[0].tagName }));",
|
||||
RAW,
|
||||
)
|
||||
|
||||
|
||||
def test_카드_보기_안내_글이_단추와_같은_줄():
|
||||
def test_카드_보기_안내_글은_카드_판_아래():
|
||||
out = _panel("cards", HINT)
|
||||
assert out["n"] == 1 # 안내 글 따로 줄 없음 · 카드 판 하나
|
||||
assert out["bar"] == "ui-group-cards__bar"
|
||||
assert out["hint"] and out["hintInBar"] # 열 수 단추 · 보기 단추와 같은 도구 줄 안
|
||||
assert out["cols"] == 4
|
||||
assert out["n"] == 2 and out["hintLast"] and not out["inBar"] # 카드 판 + 안내 글(맨 아래)
|
||||
assert "b09-estimation__line-hint" in out["hintCls"]
|
||||
|
||||
|
||||
def test_표_보기_안내_글이_보기_단추와_같은_줄():
|
||||
def test_표_보기_안내_글은_표_아래():
|
||||
out = _panel("table", HINT)
|
||||
assert out["n"] == 2 # 머리 줄 + 표
|
||||
assert out["bar"] == "b09-estimation__bar"
|
||||
assert out["hint"] and out["hintInBar"]
|
||||
assert out["n"] == 2 and out["first"] == "TABLE" and out["hintLast"] # 표 + 안내 글
|
||||
|
||||
|
||||
def test_안내_글_잘렸을_때만_공용_호버로_전문():
|
||||
@@ -82,7 +75,7 @@ def test_안내_글_잘렸을_때만_공용_호버로_전문():
|
||||
def test_안내_글_없으면_호버도_없음():
|
||||
for view in ("cards", "table"):
|
||||
out = _panel(view, "null")
|
||||
assert out["hint"] is False and out["hover"] == 0
|
||||
assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0
|
||||
|
||||
|
||||
def test_CSS_안내_글_줄임표_한_줄():
|
||||
@@ -95,10 +88,6 @@ def test_CSS_안내_글_줄임표_한_줄():
|
||||
"min-width: 0",
|
||||
):
|
||||
assert need in hint, need
|
||||
assert "flex: 1 1 0" in hint
|
||||
# 표 보기 머리 줄 = 줄바꿈 없음 · 같은 높이 가운데
|
||||
bars = re.findall(r"\.b09-estimation__bar\s*\{([^}]*)\}", page)
|
||||
assert any("flex-wrap: nowrap" in b and "align-items: center" in b for b in bars)
|
||||
|
||||
|
||||
def test_CSS_카드_같은_줄_높이_같게():
|
||||
@@ -112,10 +101,9 @@ def test_CSS_카드_같은_줄_높이_같게():
|
||||
|
||||
def test_잇기():
|
||||
groups = (ROOT / GROUPS).read_text("utf-8")
|
||||
assert "lead," in groups and "actions: [toggle]" in groups
|
||||
assert "attachHoverInfo(hint," in groups
|
||||
assert "attachHoverInfo(hint," in groups and "placeTools: o.setTools" in groups
|
||||
cards = (ROOT / COMMON).read_text("utf-8")
|
||||
assert "if (opts.lead) bar.append(opts.lead);" in cards
|
||||
assert "opts.lead" not in cards and "if (opts.placeTools) {" in cards
|
||||
|
||||
|
||||
# ── 200-3 카드 제목 줄 · 합계 줄 바탕색(밝음 · 어두움) ─────────────────────────
|
||||
|
||||
@@ -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,40 +169,22 @@ 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_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
bill = '.b09-estimation [data-b09-table="bill"]'
|
||||
# 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding
|
||||
for lv in range(2, 7):
|
||||
assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "")
|
||||
pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);"
|
||||
assert re.search(
|
||||
re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad),
|
||||
css,
|
||||
)
|
||||
assert re.search(
|
||||
re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{")
|
||||
+ r"\s*"
|
||||
+ re.escape(pad),
|
||||
css,
|
||||
)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{')
|
||||
+ r"\s*padding-left: 0;",
|
||||
css,
|
||||
)
|
||||
# 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만)
|
||||
# 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌)
|
||||
assert "is-lv" not in css and "font-size: 13px" not in css
|
||||
src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8")
|
||||
assert 'level: { key: "name", of: (i) => rows[i]?.level' in src
|
||||
# 글 칸 왼쪽 맞춤(수량만 오른쪽)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;",
|
||||
css,
|
||||
)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css
|
||||
)
|
||||
assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음
|
||||
assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표.
|
||||
|
||||
- level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음
|
||||
- mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 ·
|
||||
합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호)
|
||||
- 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
JS = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
|
||||
const file = path.join(process.argv[1], "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)));
|
||||
const m = box.exports;
|
||||
const body = (html) => html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
|
||||
const columns = [
|
||||
{ key: "g", label: "구분" }, { key: "s", label: "규격" }, { key: "n", label: "명칭" },
|
||||
{ key: "v", label: "값", kind: "number" },
|
||||
];
|
||||
const rows = [
|
||||
{ g: "흙깍기", s: "가", n: "a", v: 1 },
|
||||
{ g: "흙깍기", s: "가", n: "b", v: 1 },
|
||||
{ g: "흙깍기", s: "나", n: "c", v: 1 },
|
||||
{ g: "운반", s: "나", n: "d", v: 2 },
|
||||
{ g: "운반", s: " ", n: "e", v: 2 },
|
||||
{ g: "", s: " ", n: "f", v: 2 },
|
||||
{ g: "", s: "다", n: "g", v: 3 },
|
||||
];
|
||||
const total = { g: "계", v: 12 };
|
||||
const out = {};
|
||||
out.plain = m.renderDataTableHtml({ columns, rows, total });
|
||||
const merged = { columns, rows, total, mergeSame: ["g", "s"] };
|
||||
out.merged = body(m.renderDataTableHtml(merged));
|
||||
out.mergedFoot = m.renderDataTableHtml(merged).split("<tfoot>")[1];
|
||||
out.spans = m.mergeSpans(merged, 0, rows.length);
|
||||
out.win = m.mergeSpans(merged, 1, 4);
|
||||
out.edit = m.mergeSpans({ ...merged, editable: (i, k) => i === 1 && k === "g" }, 0, 3);
|
||||
out.level = body(m.renderDataTableHtml({
|
||||
columns, rows: rows.slice(0, 3), level: { key: "n", of: (i) => [1, 2, null][i] },
|
||||
editable: (i, k) => i === 1 && k === "n",
|
||||
}));
|
||||
out.cell = [m.cellColumn({ dataset: { c: "3" }, cellIndex: 1 }), m.cellColumn({ dataset: {}, cellIndex: 1 })];
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", JS, str(ROOT)], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_옵션_없으면_무변():
|
||||
html = _run()["plain"]
|
||||
assert "rowspan" not in html.split("<tbody>")[1]
|
||||
assert "data-c=" not in html and "--dt-lv" not in html and "ui-data-table__lv" not in html
|
||||
|
||||
|
||||
def test_mergeSame_같은_값만_rowspan():
|
||||
out = _run()
|
||||
# 구분: 흙깍기 3 · 운반 2 · 빈 값 2(안 합침) / 규격: 가 2(흙깍기 안) · 나 = 구분이 갈려 따로 · 빈칸 글 안 합침
|
||||
assert [s[0] for s in out["spans"]] == [3, 0, 0, 2, 0, 1, 1]
|
||||
assert [s[1] for s in out["spans"]] == [2, 0, 1, 1, 1, 1, 1]
|
||||
assert all(s[2] == 1 and s[3] == 1 for s in out["spans"]) # 지정 안 한 열 = 그대로
|
||||
rows = out["merged"]
|
||||
assert '<td rowspan="3" data-c="0">흙깍기</td>' in rows[0].replace(' class="is-merged"', "")
|
||||
assert 'class="is-merged"' in rows[0]
|
||||
assert (
|
||||
rows[1].count("<td") == 2 and '<td data-c="2">b</td>' in rows[1]
|
||||
) # 합쳐진 칸 빠짐 · 열 번호
|
||||
assert '<td data-num="" data-c="3">2</td>' in rows[4] # 숫자 값 그대로
|
||||
assert "rowspan" not in out["mergedFoot"] # 합계 줄 안 합침
|
||||
# 그린 구간(1~3줄) 안에서만 · 고칠 칸 안 합침
|
||||
assert out["win"] == [[2, 1, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1]]
|
||||
assert [s[0] for s in out["edit"]] == [1, 1, 1]
|
||||
assert out["cell"] == [3, 1]
|
||||
|
||||
|
||||
def test_level_보기_전용_들여쓰기():
|
||||
rows = _run()["level"]
|
||||
assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"]
|
||||
assert '<td class="ui-data-table__lv">a</td>' in rows[0]
|
||||
assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만
|
||||
assert '<td class="is-edit ui-data-table__lv" data-num="">' in rows[1] # 글 상자 칸도
|
||||
assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만)
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);"
|
||||
assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css)
|
||||
assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css)
|
||||
assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css)
|
||||
|
||||
|
||||
#: 적용 표 — [파일, 들어 있어야 할 글]
|
||||
APPLIED = [
|
||||
("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'),
|
||||
("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'level: blockLevel("text"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"),
|
||||
]
|
||||
|
||||
|
||||
def test_B08_B09_적용():
|
||||
for path, text in APPLIED:
|
||||
assert text in (ROOT / path).read_text("utf-8"), (path, text)
|
||||
# 옛 글 빈칸 지우기 · 앞 빈칸 들여쓰기는 없앰(병합 · level 로 갈음)
|
||||
mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8")
|
||||
assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat
|
||||
labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
|
||||
assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor
|
||||
# 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림)
|
||||
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2
|
||||
@@ -0,0 +1,175 @@
|
||||
"""202-1 B08 · B09 표 칸 — 안내 · 경고 줄 = 맨 아래 · 박스보기 · 가로 열 수 = 보조 탭 줄 오른쪽.
|
||||
|
||||
- B09 안내 줄 = 표 · 카드 판 아래(마지막 자식) · 묶음 탭 도구(보기 단추 · 열 수) = 아래 탭 줄 오른쪽 · 단독 탭 빈 줄에서도
|
||||
- B08 안내 · 경고 줄 = 표 · 아래 줄 뒤 맨 아래(토공 · 운반 시트 · 자재 · 구조물 집계)
|
||||
- 창 끝까지 셈(199-1) = 아래로 간 안내 줄 몫 포함
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
|
||||
|
||||
B09 = "B09_Estimation/B09_Estimation_UI_"
|
||||
BAR = f"{B09}TabBar.ts"
|
||||
DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0}
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ── B09 보조 탭 줄 오른쪽 도구 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_도구_자리_보조_줄_오른쪽_빈_줄에서도_보임():
|
||||
out = run(
|
||||
[TABS, BAR],
|
||||
"""
|
||||
const bars = createTabBars(() => {});
|
||||
const mk = (n) => { const b = document.createElement('button'); b.textContent = n; return b; };
|
||||
const snap = () => ({
|
||||
kids: bars.sub.children.map((c) => (c.classList.contains('b09-estimation__tools') ? 'TOOLS' : c.textContent)),
|
||||
last: bars.sub.children[bars.sub.children.length - 1].children.map((x) => x.textContent),
|
||||
});
|
||||
bars.draw('dg');
|
||||
bars.setTools([mk('가'), mk('나')]);
|
||||
const a = snap();
|
||||
bars.draw('compare'); // 다른 묶음으로 옮겨도 도구 자리는 그대로 마지막
|
||||
const b = snap();
|
||||
bars.draw('cost'); // 단독 탭 = 빈 보조 줄
|
||||
const c = snap();
|
||||
const ghost = bars.sub.children[0];
|
||||
bars.setTools([]);
|
||||
const d = snap();
|
||||
console.log(JSON.stringify({
|
||||
a: a.kids, aLast: a.last,
|
||||
b: b.kids.slice(-1), bLast: b.last,
|
||||
c: c.kids, cLast: c.last,
|
||||
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
|
||||
subHidden: bars.sub.attrs['aria-hidden'] ?? null, ghostHidden: ghost.attrs['aria-hidden'],
|
||||
dLast: d.last.length,
|
||||
}));
|
||||
""",
|
||||
)
|
||||
tools = "TOOLS"
|
||||
assert out["a"] == ["일위대가목록표", "일위대가표", tools] # 아래 탭 뒤 오른쪽 자리
|
||||
assert out["aLast"] == ["가", "나"]
|
||||
assert out["b"] == [tools] and out["bLast"] == ["가", "나"] # 탭을 옮겨도 도구는 그대로
|
||||
assert out["blank"] and len(out["c"]) == 2 and out["c"][1] == tools # 숨은 단추 + 도구
|
||||
assert out["cLast"] == ["가", "나"]
|
||||
assert out["subHidden"] is None and out["ghostHidden"] == "true" # 줄 전체가 아니라 숨은 단추만
|
||||
assert out["dLast"] == 0 # 비움
|
||||
|
||||
|
||||
def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
|
||||
"""쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음."""
|
||||
out = run(
|
||||
[COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
|
||||
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
|
||||
"\nconst bars = createTabBars(() => {});"
|
||||
"\nconst hint = document.createElement('p');"
|
||||
"\nconst res = {};"
|
||||
"\nfor (const view of ['cards', 'table']) {"
|
||||
"\n chosen.set('dg', view);"
|
||||
"\n bars.draw('dg'); bars.setTools([]);"
|
||||
"\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {},"
|
||||
" makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }),"
|
||||
" setTools: bars.setTools });"
|
||||
"\n const tools = bars.sub.children[bars.sub.children.length - 1];"
|
||||
"\n res[view] = { inRow: tools.all('[data-b09-group-view]').map((b) => b.textContent),"
|
||||
" cols: tools.all('[data-group-cols]').length,"
|
||||
" inPanel: panel.flatMap((e) => [e, ...e.all('[data-b09-group-view]'), ...e.all('[data-group-cols]')])"
|
||||
".filter((e) => e.dataset.b09GroupView || e.dataset.groupCols).length,"
|
||||
" hintLast: panel[panel.length - 1] === hint, n: panel.length };"
|
||||
"\n}"
|
||||
"\nconsole.log(JSON.stringify(res));",
|
||||
RAW,
|
||||
)
|
||||
assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4
|
||||
assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0
|
||||
for view in ("cards", "table"):
|
||||
assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음
|
||||
assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래)
|
||||
|
||||
|
||||
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert (
|
||||
"panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))"
|
||||
in shell
|
||||
) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침
|
||||
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
||||
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
||||
assert "setTools: bars.setTools" in shell
|
||||
|
||||
|
||||
def test_CSS_도구_오른쪽_맞춤_빈_줄_숨김_규칙에_안_걸림():
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
tools = _block(css, ".b09-estimation__tools")
|
||||
assert "margin-left: auto" in tools and "flex-wrap: nowrap" in tools
|
||||
hidden = _block(css, ".b09-estimation__tabs--blank > .ui-btn")
|
||||
assert "visibility: hidden" in hidden # 직계 단추만 — 도구 묶음은 안 숨김
|
||||
assert ".b09-estimation__bar" not in css # 옛 머리 줄(안내 + 단추) 풂
|
||||
|
||||
|
||||
# ── B08 안내 · 경고 줄 맨 아래 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_B08_표_카드_차례_표_아래줄_안내():
|
||||
src = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text("utf-8")
|
||||
fn = re.search(r"export function tableCard\(.*?\n}\n", src, re.S)
|
||||
assert fn
|
||||
code = fn.group(0).replace("export ", "")
|
||||
js = (
|
||||
"const createCard = ({ body }) => body;\n"
|
||||
+ code
|
||||
+ "\nconsole.log(JSON.stringify(tableCard(['i1', 'i2'], 'T', ['a1', 'a2'])));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||||
input=js,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["T", "a1", "a2", "i1", "i2"] # 표 · 아래 줄 · 안내 줄
|
||||
|
||||
|
||||
def test_B08_자재_구조물_안내_줄_표_아래():
|
||||
material = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text("utf-8")
|
||||
assert "createCard({ body: [toolbar, table.root, excel, notes] })" in material
|
||||
assert "toolbar.append(save);" in material # 저장 단추만 위 줄(안내는 아래)
|
||||
summary = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text("utf-8")
|
||||
assert "createCard({ body: [table, ...parts] })" in summary
|
||||
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text("utf-8")
|
||||
assert "justify-content: flex-end" in _block(css, ".b08-material__toolbar")
|
||||
|
||||
|
||||
# ── 창 끝까지 셈(199-1)에 아래 줄 몫 ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_창_끝까지_셈에_아래_안내_줄_포함():
|
||||
proc = subprocess.run(
|
||||
["node", str(ROOT / "resources" / "tester" / "helper_202_1_fill_below.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
r = json.loads(proc.stdout)
|
||||
assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백
|
||||
assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0
|
||||
assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦
|
||||
@@ -0,0 +1,112 @@
|
||||
"""203-1 B09 좌측 「데이터 점검」 묶음(사용자 60번).
|
||||
|
||||
- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버)
|
||||
- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending)
|
||||
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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_Prices as prices # noqa: E402
|
||||
from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402
|
||||
|
||||
CHECK = "B09_Estimation/B09_Estimation_UI_Check.ts"
|
||||
SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
|
||||
|
||||
def test_묶음_이름_단추_글_설명_없음():
|
||||
assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL
|
||||
assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL
|
||||
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검
|
||||
assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL
|
||||
assert "section.append(heading, refresh, check.list);" in SHELL
|
||||
assert "check.draw(payload, sheets);" in SHELL
|
||||
|
||||
|
||||
def test_연결_점검_탭_없음_첫_탭_공사원가계산서():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n),"
|
||||
" groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));",
|
||||
)
|
||||
assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"]
|
||||
assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서"
|
||||
assert "let current: TabId = TABS[0][0];" in SHELL
|
||||
assert "buildCheck" not in SHELL and "check:" not in SHELL
|
||||
|
||||
|
||||
def test_좌측_목록_이동_호버_배지():
|
||||
out = run(
|
||||
[TABS, CHECK],
|
||||
"""
|
||||
const moves = [];
|
||||
const refresh = document.createElement("button");
|
||||
const panel = createCheckPanel(refresh, (link) => moves.push(link));
|
||||
const sheets = { gaps: [
|
||||
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
|
||||
{ tab: "material", key: "MT9", name: "모래", why: "단가 없음" },
|
||||
] };
|
||||
const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2",
|
||||
notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } };
|
||||
panel.draw(payload, sheets);
|
||||
const view = () => ({
|
||||
pending: refresh.classList.contains("is-pending"),
|
||||
texts: panel.list.children.map((c) => c.text),
|
||||
rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]),
|
||||
});
|
||||
const a = view();
|
||||
const rowsBox = panel.list.children[1];
|
||||
rowsBox.children[1].click();
|
||||
const tipRefresh = hoverCalls.find((h) => h.root === refresh).text();
|
||||
const rowHover = hoverCalls.find((h) => h.root === panel.list);
|
||||
const tipRow = rowHover.text(rowsBox.children[0]);
|
||||
panel.draw({ book: null, stale: null }, { gaps: [] });
|
||||
const b = view();
|
||||
const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" });
|
||||
console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty }));
|
||||
""",
|
||||
)
|
||||
a, b = out["a"], out["b"]
|
||||
assert a["pending"] and not b["pending"]
|
||||
assert a["texts"][0] == "연결 없음 2곳"
|
||||
assert a["rows"] == [
|
||||
["0", "ui-sidebar-row", "찰쌓기일위대가표"],
|
||||
["1", "ui-sidebar-row", "모래재료비목록표"],
|
||||
]
|
||||
assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"]
|
||||
assert out["moves"] == [{"tab": "material", "key": "MT9"}]
|
||||
assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]"
|
||||
assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"]
|
||||
assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
|
||||
assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == []
|
||||
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
|
||||
|
||||
|
||||
def test_배지_조건_서버_book_stale():
|
||||
keys = ["EQ000004"] # 불도저 · 조종원 · 경유(test_144 와 같은 견본)
|
||||
book = json.loads(json.dumps(prices.refresh_book(keys), ensure_ascii=False))
|
||||
assert prices.book_stale(book, keys) is None # 방금 굳힌 책 = 업데이트 필요 없음
|
||||
assert prices.book_stale(None, keys) == "단가 아직 안 굳힘"
|
||||
first, *rest = list(book["rows"])
|
||||
old = {**book, "rows": {k: book["rows"][k] for k in rest}, "fx": []}
|
||||
old["rows"][rest[0]] = {**old["rows"][rest[0]], "price": -1}
|
||||
old["driver_factor"] = {"num": 1, "den": 1, "text": "1/1"}
|
||||
assert prices.book_stale(old, keys) == (
|
||||
"새 단가 줄 1 · M01 에서 바뀐 단가 1 · 운전사 계수 바뀜 · 환율 바뀜"
|
||||
)
|
||||
router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8")
|
||||
assert '"stale": await asyncio.to_thread(prices.book_stale, book, keys),' in router
|
||||
|
||||
|
||||
def test_배지_꼴_경고_색_토큰():
|
||||
css = (ROOT / "B09_Estimation/B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
block = css[css.index(".b09-options .ui-btn.is-pending::after") :]
|
||||
block = block[: block.index("}")]
|
||||
assert "position: absolute" in block and "border-radius: 50%" in block
|
||||
assert "background: var(--color-warning)" in block
|
||||
@@ -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]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""205-1 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄 — 단추 높이 = 상자 안 작은 단추 · 늘 보임.
|
||||
|
||||
- 높이 = `--b05-control-h`(상자 안 단추 · 입력 칸과 같은 값) · 상자 이름표 [삭제]와 같은 높이 식
|
||||
- 줄 = 스크롤 영역 아래 끝에 붙음(sticky · bottom 0) · 단추 · 줄에 hidden · display none · visibility ·
|
||||
호버 조건이 없음
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS = (ROOT / "B05_Profile/B05_Profile_UI_Style_Structures.css").read_text(encoding="utf-8")
|
||||
FORM = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Form.ts").read_text(encoding="utf-8")
|
||||
SECTION = ".b05-structure-section .b05-route__irregular-actions"
|
||||
|
||||
|
||||
def _block(selector: str) -> str:
|
||||
m = re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _decl(block: str, prop: str) -> str:
|
||||
m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block)
|
||||
assert m, prop
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def test_buttons_same_height_as_inner_buttons():
|
||||
outer = _block(SECTION + " > button")
|
||||
inner = re.search(r"\.b05-structure__group > legend > button\s*\{([^}]*)\}", CSS).group(1)
|
||||
assert _decl(outer, "height") == "var(--b05-control-h)"
|
||||
for prop in ("height", "box-sizing", "padding-block", "font-size", "line-height"):
|
||||
assert _decl(outer, prop) == _decl(inner, prop), prop
|
||||
assert re.search(r"--b05-control-h:\s*26px;", CSS)
|
||||
|
||||
|
||||
def test_row_always_visible():
|
||||
row = _block(SECTION)
|
||||
assert _decl(row, "position") == "sticky"
|
||||
assert _decl(row, "bottom") == "0"
|
||||
assert "background" in row
|
||||
for text in (_block(SECTION), _block(SECTION + " > button")):
|
||||
assert not re.search(r"display\s*:\s*none|visibility|opacity|overflow", text)
|
||||
assert not re.search(re.escape(SECTION) + r"[^{]*(:hover|:focus-within|\[hidden\])", CSS)
|
||||
|
||||
|
||||
def test_form_never_hides_buttons():
|
||||
assert "removeButton.hidden" not in FORM and "resetButton.hidden" not in FORM
|
||||
assert "actions.hidden" not in FORM
|
||||
@@ -172,3 +172,24 @@
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */
|
||||
.ui-data-table__table td.ui-data-table__lv {
|
||||
padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.ui-data-table__lv.is-edit {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.ui-data-table__lv .ui-data-table__input {
|
||||
padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */
|
||||
.ui-data-table__table td.is-merged {
|
||||
vertical-align: top;
|
||||
padding-top: calc((var(--dt-row-h) - 1.3em) / 2);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
|
||||
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
|
||||
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
|
||||
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
|
||||
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
|
||||
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_data_table.css";
|
||||
@@ -65,6 +68,10 @@ export interface DataTableOptions {
|
||||
cellClass?: (index: number, key: string) => string | null | undefined;
|
||||
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
|
||||
fillViewport?: boolean;
|
||||
/** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */
|
||||
level?: { key: string; of: (index: number) => number | null | undefined };
|
||||
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
export interface DataTable {
|
||||
@@ -145,15 +152,21 @@ function rowHtml(
|
||||
focus = false,
|
||||
extra: string | null | undefined = null,
|
||||
cellClass?: (key: string) => string | null | undefined,
|
||||
more: RowMore = {},
|
||||
): string {
|
||||
let tr = "";
|
||||
const { lv, spans } = more;
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const key = cols[j].key;
|
||||
const span = spans?.[j] ?? 1;
|
||||
if (!span) continue; // 위 칸에 합쳐짐
|
||||
const lvc = lv?.key === key ? " ui-data-table__lv" : "";
|
||||
const c = spans ? ` data-c="${j}"` : "";
|
||||
if (edit !== null && editable?.(edit, key)) {
|
||||
const v = r[key];
|
||||
const raw = v === null || v === undefined ? "" : String(v);
|
||||
const tip = esc(cols[j].title ?? cols[j].label);
|
||||
tr += `<td class="is-edit" data-num=""><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
tr += `<td class="is-edit${lvc}" data-num=""${c}><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
continue;
|
||||
}
|
||||
const [txt, empty] = formatCell(r[key], fmts[j], blank);
|
||||
@@ -163,24 +176,68 @@ function rowHtml(
|
||||
frz && left[j + 1] == null && "is-edge",
|
||||
empty && "is-empty",
|
||||
cellClass?.(key),
|
||||
lvc.trim(),
|
||||
span > 1 && "is-merged",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const attrs =
|
||||
(cls ? ` class="${cls}"` : "") +
|
||||
(frz ? ` style="left:${left[j]}px"` : "") +
|
||||
(fmts[j] ? ' data-num=""' : "");
|
||||
(fmts[j] ? ' data-num=""' : "") +
|
||||
(span > 1 ? ` rowspan="${span}"` : "") +
|
||||
c;
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
}
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const data = index !== null ? ` data-i="${index}"` : "";
|
||||
const data =
|
||||
(index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : "");
|
||||
return cls || data
|
||||
? `<tr${cls ? ` class="${esc(cls)}"` : ""}${data}>${tr}</tr>`
|
||||
: `<tr>${tr}</tr>`;
|
||||
}
|
||||
|
||||
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
|
||||
interface RowMore {
|
||||
lv?: { key: string; n: number };
|
||||
spans?: number[];
|
||||
}
|
||||
|
||||
/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */
|
||||
export function cellColumn(cell: HTMLTableCellElement): number {
|
||||
return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex;
|
||||
}
|
||||
|
||||
/** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */
|
||||
export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] {
|
||||
const cols = o.columns;
|
||||
const keys = new Set(o.mergeSame ?? []);
|
||||
const out = Array.from({ length: to - from }, () => cols.map(() => 1));
|
||||
// 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고)
|
||||
const shown = (i: number, j: number): string | null => {
|
||||
const key = cols[j].key;
|
||||
if (o.editable?.(i, key)) return null;
|
||||
const [txt] = formatCell(o.rows[i][key], null, "");
|
||||
return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침
|
||||
};
|
||||
for (let i = from + 1; i < to; i++) {
|
||||
let joined = true;
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
if (!keys.has(cols[j].key)) continue;
|
||||
const v = shown(i, j);
|
||||
joined = joined && v !== null && v === shown(i - 1, j);
|
||||
if (!joined) continue;
|
||||
let top = i - 1;
|
||||
while (!out[top - from][j]) top--;
|
||||
out[top - from][j]++;
|
||||
out[i - from][j] = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
|
||||
export interface RowWindow {
|
||||
from: number;
|
||||
@@ -216,7 +273,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
let body = win ? pad(from * win.rowH, cols.length) : "";
|
||||
const blank = o.empty ?? EMPTY;
|
||||
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
|
||||
for (let i = from; i < to; i++)
|
||||
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
|
||||
for (let i = from; i < to; i++) {
|
||||
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
|
||||
body += rowHtml(
|
||||
cols,
|
||||
fmts,
|
||||
@@ -230,7 +289,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
o.focus === i,
|
||||
o.rowClass?.(i),
|
||||
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
|
||||
{
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}),
|
||||
...(spans ? { spans: spans[i - from] } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (win) body += pad((n - to) * win.rowH, cols.length);
|
||||
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
|
||||
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
|
||||
@@ -342,7 +406,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return;
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
|
||||
const column = cell ? state.columns[cell.cellIndex] : undefined;
|
||||
const column = cell ? state.columns[cellColumn(cell)] : undefined;
|
||||
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
|
||||
state.onRowClick?.(Number(row.dataset.i));
|
||||
});
|
||||
|
||||
@@ -65,10 +65,10 @@ export interface GroupCardsOptions {
|
||||
focus?: number;
|
||||
money?: (value: number | null | undefined) => string;
|
||||
qty?: (value: number | null | undefined) => string;
|
||||
/** 머리 띠 가운데 안내 글(200-1 · 단추와 한 줄 — 늘어나는 칸) */
|
||||
lead?: HTMLElement | null;
|
||||
/** 머리 띠 끝 단추(예 [표로 보기]) */
|
||||
actions?: HTMLElement[];
|
||||
/** 주면 가로 열 수 · `actions` 를 머리 띠에 안 넣고 이 함수로 넘김(202-1 부르는 쪽 보조 탭 줄 오른쪽) */
|
||||
placeTools?: (tools: HTMLElement[]) => void;
|
||||
}
|
||||
|
||||
export const GROUP_COLUMNS = [1, 2, 3, 4] as const;
|
||||
@@ -210,12 +210,16 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
|
||||
return b;
|
||||
});
|
||||
pick.append(...colButtons);
|
||||
bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`));
|
||||
if (opts.lead) bar.append(opts.lead);
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "ui-group-cards__actions";
|
||||
tail.append(...(opts.actions ?? []));
|
||||
bar.append(tail);
|
||||
const count = span("ui-group-cards__count", `묶음 ${opts.groups.length}`);
|
||||
if (opts.placeTools) {
|
||||
bar.append(count);
|
||||
opts.placeTools([pick, ...(opts.actions ?? [])]);
|
||||
} else {
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "ui-group-cards__actions";
|
||||
tail.append(...(opts.actions ?? []));
|
||||
bar.append(pick, count, tail);
|
||||
}
|
||||
root.append(bar, grid);
|
||||
|
||||
grid.addEventListener("click", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user