Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_4
This commit is contained in:
@@ -39,6 +39,7 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
@@ -247,6 +248,18 @@ def _captured(whole: Any, node: Any, env: dict) -> list[str]:
|
||||
return seen
|
||||
|
||||
|
||||
def spec_text(whole: Any, given: dict[str, Any]) -> tuple[str, str]:
|
||||
"""규격 글(157-3) → (보일 글, 원문번호 글) — 기계 · 직종 번호 = 「이름 규격」(M02 85-1)."""
|
||||
|
||||
def shown(name: str, value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
return labor_inputs._labels(whole, {"이름": name, "고르기": [value]}).get(value, value)
|
||||
|
||||
raw = ", ".join(f"{k}={v}" for k, v in given.items())
|
||||
return ", ".join(f"{k}={shown(k, v)}" for k, v in given.items()), raw
|
||||
|
||||
|
||||
def _money(price: float | None, qty: float | None) -> float | None:
|
||||
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
|
||||
return None if price is None or qty is None else rounddown(price * qty, 1)
|
||||
@@ -302,10 +315,12 @@ class Solver:
|
||||
sums = block_totals(lines)
|
||||
why = next((ln["why"] for ln in lines if ln.get("why")), None)
|
||||
head, foot = self._text(whole, src, times, lines)
|
||||
spec, spec_raw = spec_text(whole, src["given"])
|
||||
return {
|
||||
"logic": code,
|
||||
"name": name,
|
||||
"spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()),
|
||||
"spec": spec,
|
||||
"spec_raw": spec_raw,
|
||||
"unit": unit,
|
||||
"source": row.get("출처") or "",
|
||||
"lines": lines,
|
||||
@@ -317,8 +332,8 @@ class Solver:
|
||||
|
||||
@staticmethod
|
||||
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
|
||||
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [],
|
||||
**dict.fromkeys(PARTS), "sum": None, "why": why,
|
||||
return {"logic": code, "name": name, "spec": "", "spec_raw": "", "unit": "", "source": "",
|
||||
"lines": [], **dict.fromkeys(PARTS), "sum": None, "why": why,
|
||||
"head": [], "foot": ""} # fmt: skip
|
||||
|
||||
def price_of(self, key: str) -> Any:
|
||||
@@ -453,6 +468,9 @@ class Solver:
|
||||
|
||||
|
||||
# ── 모음 ──────────────────────────────────────────────────────────────
|
||||
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
|
||||
|
||||
|
||||
def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」."""
|
||||
solver = Solver(sheets)
|
||||
@@ -461,10 +479,7 @@ def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str
|
||||
for src in rows_of(folder, whole):
|
||||
solver.add(whole, src, owner)
|
||||
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
|
||||
listing = [
|
||||
{k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")}
|
||||
for b in blocks
|
||||
]
|
||||
listing = [{k: b[k] for k in LISTED} for b in blocks]
|
||||
gaps = [
|
||||
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
|
||||
for b in blocks
|
||||
|
||||
@@ -30,3 +30,83 @@
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 157-2 산근 탭 머리 — 안내 글 + 보기 단추 */
|
||||
.b09-estimation__bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 157-2 산근 호표별 카드 — 호표 사이 또렷이 · 안은 가름줄 없이 간격 */
|
||||
.b09-sg-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b09-sg-card__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-8);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.b09-sg-card__no {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-sg-card__unit {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b09-sg-card__total {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b09-sg-card__line {
|
||||
display: grid;
|
||||
grid-template-columns: 7em minmax(0, 1fr) repeat(4, 7.5em) 10em;
|
||||
column-gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.b09-sg-card__cols,
|
||||
.b09-sg-card__sub {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b09-sg-card__line.is-title {
|
||||
margin-top: var(--spacing-4);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.b09-sg-card__line.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b09-sg-card__line.is-clickable:hover,
|
||||
.b09-sg-card__line.is-focus,
|
||||
.b09-sg-card.is-focus {
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.b09-sg-card__text,
|
||||
.b09-sg-card__note {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b09-sg-card__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b09-sg-card__cols .b09-sg-card__num {
|
||||
font-variant-numeric: normal;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
import { renderSgPanel } from "./B09_Estimation_UI_Tabs_Sangeun_Cards";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -190,23 +191,45 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const goto = (link: Link | null): void => {
|
||||
if (link) show(link.tab, link.key);
|
||||
};
|
||||
const table = createDataTable({
|
||||
columns: spec.columns,
|
||||
rows: spec.rows,
|
||||
total: spec.total ?? null,
|
||||
frozen: spec.frozen ?? 0,
|
||||
empty: "",
|
||||
caption: TABS.find(([id]) => id === tab)?.[1],
|
||||
editable: spec.editable,
|
||||
onEdit: spec.onEdit,
|
||||
rowClass: spec.rowClass,
|
||||
onCellClick: (i, key) => {
|
||||
if (spec.onCell) spec.onCell(i, key);
|
||||
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
|
||||
},
|
||||
});
|
||||
table.root.dataset.b09Table = tab;
|
||||
panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root);
|
||||
const makeTable = (): DataTable => {
|
||||
const made = createDataTable({
|
||||
columns: spec.columns,
|
||||
rows: spec.rows,
|
||||
total: spec.total ?? null,
|
||||
frozen: spec.frozen ?? 0,
|
||||
empty: "",
|
||||
caption: TABS.find(([id]) => id === tab)?.[1],
|
||||
editable: spec.editable,
|
||||
onEdit: spec.onEdit,
|
||||
rowClass: spec.rowClass,
|
||||
onCellClick: (i, key) => {
|
||||
if (spec.onCell) spec.onCell(i, key);
|
||||
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
|
||||
},
|
||||
});
|
||||
made.root.dataset.b09Table = tab;
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
|
||||
if (spec.tip)
|
||||
made.root.addEventListener("mouseover", (event) => {
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
|
||||
const row = cell?.parentElement;
|
||||
const column = cell ? spec.columns[cell.cellIndex] : undefined;
|
||||
if (!cell || !row || !column) return;
|
||||
const tip = spec.tip?.(Number(row.dataset.i), column.key);
|
||||
if (tip) cell.title = tip;
|
||||
});
|
||||
return made;
|
||||
};
|
||||
const hint = spec.hint ? note(spec.hint) : null;
|
||||
// 157-2 산근 = 보기 둘(표 · 호표별 카드) — 머리 단추로 바꿈
|
||||
if (tab === "sg") {
|
||||
const redraw = (): void => show("sg");
|
||||
return void panel.replaceChildren(
|
||||
...renderSgPanel({ spec, hint, focus, goto, makeTable, redraw }),
|
||||
);
|
||||
}
|
||||
const table = makeTable();
|
||||
panel.replaceChildren(...(hint ? [hint] : []), table.root);
|
||||
if (focus !== undefined) {
|
||||
const index = rowIndex(spec.rows, focus);
|
||||
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface TabTable {
|
||||
onCell?: (index: number, key: string) => void;
|
||||
/** 줄 꼴 class(공용 표 `rowClass` · 예 제목 줄 굵게) */
|
||||
rowClass?: (index: number) => string | null | undefined;
|
||||
/** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */
|
||||
tip?: (index: number, key: string) => string | null | undefined;
|
||||
}
|
||||
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
@@ -29,6 +29,10 @@ const PART_NAME: Record<string, string> = {
|
||||
};
|
||||
const PARTS = ["labor", "material", "expense"] as const;
|
||||
|
||||
/** 157-3 규격 풍선 = 원문번호 글(「이름 규격」 으로 바꾼 칸만) */
|
||||
const raw = (shown: unknown, codes: unknown): string | null =>
|
||||
codes && codes !== shown ? `원문번호 ${String(codes)}` : null;
|
||||
|
||||
const notes = (sheets: Row): string =>
|
||||
((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join("");
|
||||
|
||||
@@ -55,6 +59,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => {
|
||||
"산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
|
||||
notes(sheets),
|
||||
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
|
||||
tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,7 +86,19 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
|
||||
for (const b of blocks) {
|
||||
const head: string[] = b.head?.length ? b.head : [`${b.name} / ${b.unit}`];
|
||||
push({ key: b.key, kind: `제 ${b.no} 호표`, text: head[0], note: b.spec }, null, true);
|
||||
push(
|
||||
{
|
||||
key: b.key,
|
||||
kind: `제 ${b.no} 호표`,
|
||||
text: head[0],
|
||||
note: b.spec,
|
||||
note_raw: b.spec_raw,
|
||||
name: b.name,
|
||||
unit: b.unit,
|
||||
},
|
||||
null,
|
||||
true,
|
||||
);
|
||||
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;
|
||||
@@ -145,5 +162,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
tip: (i: number, key: string) =>
|
||||
key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Sangeun_Cards.ts
|
||||
* B09 단가산출근거 탭 보기 둘(PLAN 157-2) — 보기 A = 표(`buildSg` 그대로) · 보기 B = 호표별 카드.
|
||||
*
|
||||
* 카드 = `buildSg` 가 낸 같은 줄을 호표(줄 키 앞머리)마다 묶어 그림 — 값 · 글은 표와 같음
|
||||
* 머리 = 산근 n호표 · 공종 · 단위 · 합계 · 몸 = 산출내역 · 합계 · 노 · 재 · 경 · 비고 줄(가름줄 없이 간격)
|
||||
* 탭 머리 단추 = 보기 바꿈 · 고른 보기 = localStorage(`b09-sg-view`) · 못 읽으면 표
|
||||
* 줄 누름 = 표와 같은 이동(`spec.link`) · 이동해 온 줄 = 스크롤 + 강조
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
|
||||
import { formatCell } from "@ui/ui_template_data_table";
|
||||
import { createButton, createCard } from "@ui/ui_template_elements";
|
||||
import { rowIndex, type Link, type TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export type SgView = "table" | "cards";
|
||||
|
||||
export const SG_VIEW_KEY = "b09-sg-view";
|
||||
|
||||
/** 단추 글 = 바꿀 보기 이름 */
|
||||
export const sgViewLabel = (view: SgView): string =>
|
||||
view === "cards" ? "표로 보기" : "호표별 보기";
|
||||
|
||||
export const flipSgView = (view: SgView): SgView => (view === "cards" ? "table" : "cards");
|
||||
|
||||
type Store = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
function localStore(): Store | null {
|
||||
try {
|
||||
return localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 고른 보기 — 못 읽거나 모르는 값 = 표 */
|
||||
export function readSgView(store: Store | null = localStore()): SgView {
|
||||
try {
|
||||
return store?.getItem(SG_VIEW_KEY) === "cards" ? "cards" : "table";
|
||||
} catch {
|
||||
return "table";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSgView(view: SgView, store: Store | null = localStore()): void {
|
||||
try {
|
||||
store?.setItem(SG_VIEW_KEY, view);
|
||||
} catch {
|
||||
// 기억 못 해도 이 화면 안에서는 바뀐 보기 그대로
|
||||
}
|
||||
}
|
||||
|
||||
/** 카드 하나 = 호표 한 블록 — 줄 번호(`at`)는 표 줄 번호(이동 · 강조) */
|
||||
export interface SgCard {
|
||||
key: string;
|
||||
at: number;
|
||||
head: DataRow;
|
||||
lines: Array<{ at: number; row: DataRow }>;
|
||||
}
|
||||
|
||||
/** 표 줄 → 호표 카드(줄 키 `호표키|…` 앞머리로 묶음 · 줄은 그 객체 그대로) */
|
||||
export function sgCards(rows: DataRow[]): SgCard[] {
|
||||
const cards: SgCard[] = [];
|
||||
const byKey = new Map<string, SgCard>();
|
||||
rows.forEach((row, at) => {
|
||||
const key = String(row.key ?? "").split("|")[0];
|
||||
const card = byKey.get(key);
|
||||
if (card) return void card.lines.push({ at, row });
|
||||
const made: SgCard = { key, at, head: row, lines: [] };
|
||||
byKey.set(key, made);
|
||||
cards.push(made);
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
/* ── 그리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const MONEY = ["sum", "labor", "material", "expense"] as const;
|
||||
const LABEL: Record<string, string> = {
|
||||
kind: "공종",
|
||||
text: "산출내역",
|
||||
sum: "합계",
|
||||
labor: "노무비",
|
||||
material: "재료비",
|
||||
expense: "경비",
|
||||
note: "비고 · 까닭",
|
||||
};
|
||||
|
||||
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
|
||||
function formats(spec: TabTable): Record<string, Intl.NumberFormat> {
|
||||
return Object.fromEntries(
|
||||
spec.columns
|
||||
.filter((c) => c.kind === "number")
|
||||
.map((c) => [
|
||||
c.key,
|
||||
new Intl.NumberFormat("ko-KR", {
|
||||
minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0),
|
||||
maximumFractionDigits: c.decimals ?? 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function span(className: string, value: string): HTMLSpanElement {
|
||||
const s = document.createElement("span");
|
||||
s.className = className;
|
||||
s.textContent = value;
|
||||
return s;
|
||||
}
|
||||
|
||||
function line(
|
||||
row: DataRow,
|
||||
fmt: Record<string, Intl.NumberFormat>,
|
||||
className: string,
|
||||
tip?: string | null,
|
||||
): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.className = className;
|
||||
div.append(
|
||||
span("b09-sg-card__kind", String(row.kind ?? "")),
|
||||
span("b09-sg-card__text", String(row.text ?? "").trimStart()),
|
||||
...MONEY.map((k) => span("b09-sg-card__num", formatCell(row[k], fmt[k] ?? null, "")[0])),
|
||||
span("b09-sg-card__note", String(row.note ?? "")),
|
||||
);
|
||||
if (tip) (div.lastElementChild as HTMLElement).title = tip;
|
||||
return div;
|
||||
}
|
||||
|
||||
function cardsView(
|
||||
spec: TabTable,
|
||||
goto: (link: Link | null) => void,
|
||||
focus: string | undefined,
|
||||
): HTMLElement {
|
||||
const fmt = formats(spec);
|
||||
const host = document.createElement("div");
|
||||
host.className = "b09-sg-cards";
|
||||
host.dataset.b09Table = "sg";
|
||||
const tip = (at: number) => spec.tip?.(at, "note");
|
||||
const clickable = (el: HTMLElement, at: number): void => {
|
||||
el.dataset.i = String(at);
|
||||
if (spec.link?.(at, "kind")) el.classList.add("is-clickable");
|
||||
};
|
||||
for (const c of sgCards(spec.rows)) {
|
||||
const head = document.createElement("div");
|
||||
head.className = "b09-sg-card__head";
|
||||
const no = String(c.head.kind ?? "").replace(/^제\s*(\d+)\s*호표$/, "산근 $1호표");
|
||||
head.append(
|
||||
span("b09-sg-card__no", no),
|
||||
span("b09-sg-card__name", String(c.head.name ?? c.head.text ?? "")),
|
||||
span("b09-sg-card__unit", String(c.head.unit ?? "")),
|
||||
span(
|
||||
"b09-sg-card__total",
|
||||
`합계 ${formatCell(c.lines[c.lines.length - 1]?.row.sum, fmt.sum ?? null, "")[0]}`,
|
||||
),
|
||||
);
|
||||
const sub = line({ ...c.head, kind: "" }, fmt, "b09-sg-card__line b09-sg-card__sub", tip(c.at));
|
||||
const cols = line(LABEL, {}, "b09-sg-card__line b09-sg-card__cols");
|
||||
const lines = c.lines.map(({ at, row }) => {
|
||||
const cls = spec.rowClass?.(at) ? " is-title" : "";
|
||||
const el = line(row, fmt, `b09-sg-card__line${cls}`, tip(at));
|
||||
clickable(el, at);
|
||||
return el;
|
||||
});
|
||||
const card = createCard({ body: [head, cols, sub, ...lines] });
|
||||
card.classList.add("b09-sg-card");
|
||||
clickable(card, c.at);
|
||||
host.append(card);
|
||||
}
|
||||
host.addEventListener("click", (event) => {
|
||||
const el = (event.target as Element).closest<HTMLElement>("[data-i]");
|
||||
if (el && host.contains(el)) goto(spec.link?.(Number(el.dataset.i), "kind") ?? null);
|
||||
});
|
||||
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
|
||||
if (at >= 0)
|
||||
requestAnimationFrame(() => {
|
||||
const el = host.querySelector<HTMLElement>(`[data-i="${at}"]`);
|
||||
el?.classList.add("is-focus");
|
||||
el?.scrollIntoView({ block: "center" });
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
|
||||
let chosen: SgView | null = null;
|
||||
|
||||
/** 산근 탭 — 머리(안내 + 보기 단추) · 고른 보기(표 · 카드) */
|
||||
export function renderSgPanel(o: {
|
||||
spec: TabTable;
|
||||
hint: HTMLElement | null;
|
||||
focus?: string;
|
||||
goto: (link: Link | null) => void;
|
||||
makeTable: () => DataTable;
|
||||
redraw: () => void;
|
||||
}): HTMLElement[] {
|
||||
const view = chosen ?? readSgView();
|
||||
const toggle = createButton({
|
||||
label: sgViewLabel(view),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
chosen = flipSgView(view);
|
||||
writeSgView(chosen);
|
||||
o.redraw();
|
||||
},
|
||||
});
|
||||
toggle.dataset.b09SgView = view;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-estimation__bar";
|
||||
bar.append(...(o.hint ? [o.hint] : []), toggle);
|
||||
if (view === "cards") return [bar, cardsView(o.spec, o.goto, o.focus)];
|
||||
const table = o.makeTable();
|
||||
if (o.focus !== undefined) {
|
||||
const index = rowIndex(o.spec.rows, o.focus);
|
||||
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
||||
}
|
||||
return [bar, table.root];
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」.
|
||||
|
||||
- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 줄 객체 · 같은 차례
|
||||
- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 표(A)
|
||||
- 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip)
|
||||
"""
|
||||
|
||||
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 B09_Estimation.B09_Estimation_Sangeun import spec_text # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
|
||||
|
||||
def _node(js_body):
|
||||
src = "".join(
|
||||
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||||
for n in ("UI_Tabs", "UI_Tabs_Sangeun", "UI_Tabs_Sangeun_Cards")
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src + js_body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _block(key, no, spec="", spec_raw=""):
|
||||
return {
|
||||
"key": key, "no": no, "name": f"산근{no}", "unit": "m3", "spec": spec, "spec_raw": spec_raw,
|
||||
"head": [f"산근{no} / m3", "산림품셈 적용"],
|
||||
"lines": [
|
||||
{"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"},
|
||||
"why": None, "labor": 2979.8, "material": 0.0, "expense": 0.0,
|
||||
"text": ["보통인부 : 165,545 * 0.018 = 2,979.8 (긴 함수 " +"가" * 200 + ")"]},
|
||||
{"kind": "machine", "name": "굴착기", "ref": {"tab": "machine", "key": "EQ1"},
|
||||
"why": None, "labor": 3899.0, "material": 538.6, "expense": 912.8,
|
||||
"text": ["굴착기 노무비", "굴착기 재료비", "굴착기 경비"]},
|
||||
],
|
||||
"labor": 6878, "material": 538, "expense": 912, "sum": 8328, "why": None,
|
||||
"foot": "계 : 8,328",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
SHEETS = {"sg": [_block("SG:A", 1), _block("SG:B", 2)]}
|
||||
|
||||
|
||||
def test_157_2_두_보기_같은_값():
|
||||
out = _node(
|
||||
f"\nconst t = buildSg({{ sheets: {json.dumps(SHEETS, ensure_ascii=False)} }});"
|
||||
"\nconst cards = sgCards(t.rows);"
|
||||
"\nconst flat = cards.flatMap((c) => [c.head, ...c.lines.map((l) => l.row)]);"
|
||||
"\nconst ats = cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at)]);"
|
||||
"\nconsole.log(JSON.stringify({ n: t.rows.length, keys: cards.map((c) => c.key),"
|
||||
" same: flat.length === t.rows.length && flat.every((r, i) => r === t.rows[i]),"
|
||||
" ats, heads: cards.map((c) => [c.head.kind, c.head.name, c.head.unit]),"
|
||||
" links: cards.map((c) => c.lines.map((l) => t.link(l.at, 'kind')?.key ?? null)) }));"
|
||||
)
|
||||
assert out["keys"] == ["SG:A", "SG:B"]
|
||||
assert out["same"] is True # 카드 줄 = 표 줄 객체 그대로 · 같은 차례
|
||||
assert out["ats"] == list(range(out["n"]))
|
||||
assert out["heads"] == [["제 1 호표", "산근1", "m3"], ["제 2 호표", "산근2", "m3"]]
|
||||
# 머리 1 · 노무 1 · 중기 3 · 총계 1 — 줄 누름 이동은 표와 같은 link
|
||||
assert out["links"][0] == [None, "LB2", "EQ1", "EQ1", "EQ1", None]
|
||||
|
||||
|
||||
def test_157_2_단추_바꿈():
|
||||
out = _node(
|
||||
"\nconsole.log(JSON.stringify([sgViewLabel('table'), sgViewLabel('cards'),"
|
||||
" flipSgView('table'), flipSgView(flipSgView('table'))]));"
|
||||
)
|
||||
assert out == ["호표별 보기", "표로 보기", "cards", "table"]
|
||||
|
||||
|
||||
def test_157_2_기억_실패_때_표():
|
||||
out = _node(
|
||||
"\nconst bad = { getItem: () => { throw new Error('막힘'); },"
|
||||
" setItem: () => { throw new Error('막힘'); } };"
|
||||
"\nconst mem = new Map();"
|
||||
"\nconst ok = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) };"
|
||||
"\nwriteSgView('cards', bad);"
|
||||
"\nconst junk = { getItem: () => 'x', setItem: () => {} };"
|
||||
"\nconst a = [readSgView(bad), readSgView(null), readSgView(junk), readSgView(ok)];"
|
||||
"\nwriteSgView('cards', ok);"
|
||||
"\nconsole.log(JSON.stringify([...a, readSgView(ok), mem.get(SG_VIEW_KEY)]));"
|
||||
)
|
||||
assert out == ["table", "table", "table", "table", "cards", "cards"]
|
||||
|
||||
|
||||
def test_157_2_화면_잇기():
|
||||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert 'tab === "sg"' in shell and "renderSgPanel(" in shell
|
||||
cards = (B09 / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts").read_text("utf-8")
|
||||
assert "createCard(" in cards and "createButton(" in cards
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
assert "white-space: pre-wrap" in css # 긴 산출내역 글 = 카드 안 줄바꿈
|
||||
|
||||
|
||||
class _Whole:
|
||||
numbers = {
|
||||
"EQ": {"0201-0070": {"이름": "굴착기(무한궤도)", "규격": "0.6㎥"}},
|
||||
"LB": {"1001": {"이름": "보통인부"}},
|
||||
}
|
||||
|
||||
|
||||
def test_157_3_규격_이름_규격():
|
||||
given = {"기계": "0201-0070", "직종": "1001", "규격": 150, "모름": "9999-9999"}
|
||||
shown, raw = spec_text(_Whole(), given)
|
||||
assert shown == "기계=굴착기(무한궤도) 0.6㎥, 직종=보통인부, 규격=150, 모름=9999-9999"
|
||||
assert raw == "기계=0201-0070, 직종=1001, 규격=150, 모름=9999-9999"
|
||||
|
||||
|
||||
def test_157_3_번호는_풍선():
|
||||
block = _block("SG:A", 1, "기계=굴착기(무한궤도) 0.6㎥", "기계=0201-0070")
|
||||
plain = _block("SG:B", 2, "규격=150", "규격=150")
|
||||
listing = [{k: b[k] for k in ("key", "no", "name", "spec", "spec_raw", "unit")}
|
||||
for b in (block, plain)] # fmt: skip
|
||||
sg_json = json.dumps([block, plain], ensure_ascii=False)
|
||||
ls_json = json.dumps(listing, ensure_ascii=False)
|
||||
out = _node(
|
||||
f"\nconst sg = buildSg({{ sheets: {{ sg: {sg_json} }} }});"
|
||||
f"\nconst ls = buildSgList({{ sheets: {{ sg_list: {ls_json} }} }});"
|
||||
"\nconst heads = sg.rows.map((r, i) => [r, i])"
|
||||
".filter(([r]) => !String(r.key).includes('|'));"
|
||||
"\nconsole.log(JSON.stringify({"
|
||||
" sg: heads.map(([r, i]) => [r.note, sg.tip(i, 'note') ?? null]),"
|
||||
" ls: [0, 1].map((i) => [ls.rows[i].spec, ls.tip(i, 'spec') ?? null]),"
|
||||
" other: ls.tip(0, 'name') ?? null }));"
|
||||
)
|
||||
assert out["sg"] == [
|
||||
["기계=굴착기(무한궤도) 0.6㎥", "원문번호 기계=0201-0070"],
|
||||
["규격=150", None],
|
||||
]
|
||||
assert out["ls"] == [
|
||||
["기계=굴착기(무한궤도) 0.6㎥", "원문번호 기계=0201-0070"],
|
||||
["규격=150", None],
|
||||
]
|
||||
assert out["other"] is None
|
||||
Reference in New Issue
Block a user