Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1

This commit is contained in:
2026-10-10 08:48:52 +09:00
24 changed files with 1150 additions and 354 deletions
+3
View File
@@ -66,6 +66,9 @@ export const STATE_REGISTRY = {
"m02-card-pills": { bucket: "pref", scope: "global" },
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
"m01-logic-panel": { bucket: "pref", scope: "global" },
/** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */
"b09-group-view": { bucket: "pref", scope: "global" },
"b09-group-cols": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
@@ -27,5 +27,5 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
const need = fordHeightM(designFlowM3s, widthM);
if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`;
return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력";
return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력";
}
+3 -1
View File
@@ -188,7 +188,9 @@ function excelDiffs(s: MaterialSheets): string {
if (want != null && (row.surcharge ?? 0) !== want)
out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`);
}
return out.length ? `2공구 엑셀 손값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` : "";
return out.length
? `2공구 엑셀 수기 입력값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거`
: "";
}
/** 콘크리트소요 원천 = 콘크리트(기계) · 모르터 1:3 열 + 그 둘에 맞는 사본 자재 열(짝 계산 `matched` 그대로) */
+1 -1
View File
@@ -119,7 +119,7 @@ def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> li
PriceOf = Callable[[str], dict[str, Any] | None]
WHY_HAND = "손 줄 — 연결 없음(우클릭 [연결])"
WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"
WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"
+1 -1
View File
@@ -108,7 +108,7 @@ function materialItems(inputs: Row, compare: Row[]): Row[] {
/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */
export type PriceOf = (owner: string) => Row | null;
const WHY_HAND = "손 줄 — 연결 없음(우클릭 [연결])";
const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])";
const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)";
/** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */
+1 -1
View File
@@ -483,7 +483,7 @@ def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str,
continue
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
out.append((row["key"], f"손 줄 {row.get('name') or row['key']}", src))
out.append((row["key"], f"수기 줄 {row.get('name') or row['key']}", src))
return out
+1 -73
View File
@@ -31,7 +31,7 @@
overflow-wrap: anywhere;
}
/* 157-2 산근 탭 머리 — 안내 글 + 보기 단추 */
/* 171-3 묶음 탭 머리 — 안내 글 + 보기 단추 */
.b09-estimation__bar {
display: flex;
align-items: flex-start;
@@ -39,78 +39,6 @@
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;
}
/* 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);
+5 -5
View File
@@ -57,7 +57,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";
import { isGroupTab, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -238,11 +238,11 @@ export function renderB09Estimation(root: HTMLElement): void {
return made;
};
const hint = spec.hint ? note(spec.hint) : null;
// 157-2 산근 = 보기 둘(표 · 호표별 카드) — 머리 단추로 바꿈
if (tab === "sg") {
const redraw = (): void => show("sg");
// 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표
if (isGroupTab(tab)) {
const redraw = (): void => show(tab);
return void panel.replaceChildren(
...renderSgPanel({ spec, hint, focus, goto, makeTable, redraw }),
...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }),
);
}
const table = makeTable();
@@ -157,7 +157,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 손입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적",
onEdit: (i, key, value) => {
const row = out[i];
@@ -264,7 +264,14 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
const rows: DataRow[] = [];
const links: Array<{ tab: TabId; key: string } | null> = [];
(sheets.machine_blocks || []).forEach((b: Row, n: number) => {
rows.push({ key: b.key, name: `제 ${n + 1} 호표 ${b.name}`, spec: b.spec, unit: b.unit });
rows.push({
key: b.key,
name: `제 ${n + 1} 호표 ${b.name}`,
no: n + 1,
title: b.name, // 171-3 묶음 카드 머리
spec: b.spec,
unit: b.unit,
});
links.push(null);
for (const ln of b.lines) {
const at = (part: string): number | null => (ln.kind === part ? ln.price : null);
@@ -0,0 +1,207 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Groups.ts
* B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 ·
* [표로 보기] = 공용 표 그대로.
*
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* ========================================================================== */
import type { DataRow, DataTable } from "@ui/ui_template_data_table";
import { createButton } from "@ui/ui_template_elements";
import {
clampColumns,
createGroupCards,
type GroupCard,
type GroupFoot,
type GroupLine,
type GroupParts,
} from "@ui/ui_template_group_cards";
import { readState, writeState } from "../A00_Common/b_page_state";
import { rowIndex, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs";
export type GroupView = "cards" | "table";
/** 묶음 카드로 보이는 탭 */
export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"];
export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab);
/** 단추 글 = 바꿀 보기 이름 */
export const viewLabel = (view: GroupView): string =>
view === "cards" ? "표로 보기" : "호표별 보기";
export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards");
type Saved = Record<string, unknown> | null;
const saved = (name: "b09-group-view" | "b09-group-cols"): Saved => {
try {
return readState<Record<string, unknown>>(name);
} catch {
return null;
}
};
/** 고른 보기 — 모르는 값 · 못 읽음 = 카드 */
export const readGroupView = (tab: TabId, kept: Saved = saved("b09-group-view")): GroupView =>
kept?.[tab] === "table" ? "table" : "cards";
/** 고른 가로 열 수 — 1~4 · 모르는 값 = 2 */
export const readGroupCols = (tab: TabId, kept: Saved = saved("b09-group-cols")): number =>
clampColumns(kept?.[tab]);
export function rememberGroup(
name: "b09-group-view" | "b09-group-cols",
tab: TabId,
value: unknown,
): void {
try {
writeState(name, { ...(saved(name) ?? {}), [tab]: value });
} catch {
// 기억 못 해도 이 화면 안에서는 고른 값 그대로
}
}
/* ── 표 줄 → 묶음 ─────────────────────────────────────────────────────── */
const num = (v: unknown): number | null => (typeof v === "number" ? v : null);
const str = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const parts = (r: DataRow): GroupParts => [num(r.labor), num(r.material), num(r.expense)];
/** 들여쓰기 · 「ㄴ」 뗀 이름 */
const bare = (v: unknown): string => str(v).replace(/^\s*(ㄴ\s+)?/, "");
const isFoot = (key: string): boolean => /\|(합계|총계)$/.test(key);
/** 탭마다 머리 · 몸 줄 칸 */
function headOf(tab: TabId, r: DataRow, at: number, spec: TabTable): Omit<GroupCard, "lines"> {
const tip = (key: string) => spec.tip?.(at, key) ?? null;
if (tab === "sg")
return {
key: str(r.key),
at,
no: str(r.kind),
name: str(r.name ?? r.text),
spec: str(r.note),
specTip: tip("note"),
unit: str(r.unit),
};
return {
key: str(r.key),
at,
no: `제 ${str(r.no)} 호표`,
name: str(r.title ?? r.name),
spec: str(r.spec),
specTip: tip("spec"),
unit: str(r.unit),
};
}
function lineOf(tab: TabId, r: DataRow, at: number, spec: TabTable, first: string): GroupLine {
const tip = (key: string) => spec.tip?.(at, key) ?? null;
const base = {
at,
sum: num(r.sum),
parts: parts(r),
note: str(r.note),
noteTip: tip("note"),
link: !!spec.link?.(at, first),
};
if (tab === "sg") return { ...base, name: bare(r.text), tag: str(r.kind) };
return {
...base,
name: bare(r.name),
spec: str(r.spec),
specTip: tip("spec"),
qty: num(r.qty),
unit: str(r.unit),
};
}
const footOf = (r: DataRow, at: number, spec: TabTable): GroupFoot => ({
at,
parts: parts(r),
sum: num(r.sum),
note: str(r.note),
tip: spec.tip?.(at, "sum") ?? spec.tip?.(at, "note") ?? null,
});
/** 탭 표 → 묶음 카드(줄 차례 · 표 줄 번호 그대로) */
export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] {
const first = spec.columns[0]?.key ?? "";
const cards: GroupCard[] = [];
const byKey = new Map<string, GroupCard>();
spec.rows.forEach((r, at) => {
const key = str(r.key);
const group = byKey.get(key.split("|")[0]);
if (!group) {
const made: GroupCard = { ...headOf(tab, r, at, spec), lines: [] };
byKey.set(key.split("|")[0], made);
return void cards.push(made);
}
if (isFoot(key)) group.foot = footOf(r, at, spec);
else group.lines.push(lineOf(tab, r, at, spec, first));
});
for (const c of cards) c.sum = c.foot?.sum ?? null;
return cards;
}
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string {
const c = spec.columns.find((col) => col.key === key);
const f = new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c?.trim ? 0 : (c?.decimals ?? 0),
maximumFractionDigits: c?.decimals ?? 0,
});
return (v) => (v === null || v === undefined || !Number.isFinite(v) ? "" : f.format(v));
}
/* ── 그리기 ───────────────────────────────────────────────────────────── */
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
const chosen = new Map<TabId, GroupView>();
/** 묶음 탭 — 머리(안내 + 보기 단추) · 고른 보기(카드 · 표) */
export function renderGroupPanel(o: {
tab: TabId;
spec: TabTable;
hint: HTMLElement | null;
focus?: string;
goto: (link: Link | null) => void;
makeTable: () => DataTable;
redraw: () => void;
}): HTMLElement[] {
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
const toggle = createButton({
label: viewLabel(view),
variant: "ghost",
size: "sm",
onClick: () => {
chosen.set(o.tab, flipView(view));
rememberGroup("b09-group-view", o.tab, flipView(view));
o.redraw();
},
});
toggle.dataset.b09GroupView = view;
const bar = document.createElement("div");
bar.className = "b09-estimation__bar";
bar.append(...(o.hint ? [o.hint] : []), toggle);
const at = o.focus === undefined ? -1 : rowIndex(o.spec.rows, o.focus);
if (view === "cards") {
const first = o.spec.columns[0]?.key ?? "";
const cards = createGroupCards({
groups: tabGroups(o.tab, o.spec),
columns: readGroupCols(o.tab),
onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n),
onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null),
focus: at,
money: numberFormat(o.spec, "sum"),
qty: numberFormat(o.spec, "qty"),
});
cards.dataset.b09Table = o.tab;
return [bar, cards];
}
const table = o.makeTable();
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
return [bar, table.root];
}
@@ -86,6 +86,8 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
rows.push({
key: b.id,
name: `제 ${b.no} 호표 ${b.name}`,
no: b.no,
title: b.name, // 171-3 묶음 카드 머리
spec: b.spec,
spec_raw: b.spec_raw,
unit: b.unit,
@@ -1,217 +0,0 @@
/* =============================================================================
* 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];
}
@@ -191,7 +191,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
money("material", "재료비"),
money("expense", "경비"),
qty("rate", "요율(%)", 5),
money("hand", "손입력", undefined, 2),
money("hand", "수기 입력", undefined, 2),
text("note", "산식", 320),
text("candidate", "요율 출처", 260),
],
@@ -200,7 +200,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
hint:
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
"손입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " +
"수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " +
"공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
editable: (i, key) =>
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
@@ -0,0 +1,94 @@
"""171 묶음 카드 시험 도우미 — TS 소스를 이어 붙여 Node(타입 지움)로 돌림 · 작은 가짜 DOM.
시험 파일 아님(test_ 머리 없음) — test_171_2 · test_171_3 · test_157_2 가 씀.
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
COMMON = "ui_template/ui_template_group_cards.ts"
TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts"
GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts"
# 기억(b_page_state) 대신 Map · 단추(createButton) 대신 가짜 button · 157-3 원문번호 풍선(raw) 짝
STUBS = r"""
const kept = new Map();
const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null);
const writeState = (name, value) => kept.set(name, JSON.stringify(value));
const createButton = (o) => {
const b = document.createElement("button");
b.className = `ui-btn ui-btn--${o.variant}`;
b.textContent = o.label;
if (o.onClick) b.addEventListener("click", o.onClick);
return b;
};
"""
RAW = r"""
const raw = (shown, codes) => (codes && codes !== shown ? `원문번호 ${String(codes)}` : null);
const SOURCES = [];
const openQuotes = () => {};
"""
FAKE_DOM = r"""
class E {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = []; this.parent = null; this.dataset = {}; this.attrs = {};
this.cls = new Set(); this.textContent = ""; this.title = ""; this.on = {};
const props = {};
this.style = { props, setProperty: (k, v) => { props[k] = v; } };
const self = this;
this.classList = {
add: (...c) => c.forEach((x) => self.cls.add(x)),
remove: (...c) => c.forEach((x) => self.cls.delete(x)),
contains: (c) => self.cls.has(c),
toggle: (c, force) => {
const on = force === undefined ? !self.cls.has(c) : force;
on ? self.cls.add(c) : self.cls.delete(c);
return on;
},
};
}
get className() { return [...this.cls].join(" "); }
set className(v) { this.cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
append(...xs) { for (const x of xs) { x.parent = this; this.children.push(x); } }
prepend(...xs) { for (const x of xs.reverse()) { x.parent = this; this.children.unshift(x); } }
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
setAttribute(k, v) { this.attrs[k] = String(v); }
scrollIntoView() { this.scrolled = true; }
matches(sel) {
if (sel.startsWith(".")) return this.cls.has(sel.slice(1));
const m = sel.match(/^\[data-([\w-]+)(?:="(.*)")?\]$/);
const k = m ? m[1].replace(/-(\w)/g, (_, c) => c.toUpperCase()) : "";
if (m) return m[2] === undefined ? k in this.dataset : this.dataset[k] === m[2];
return this.tagName === sel.toUpperCase();
}
closest(sel) { for (let e = this; e; e = e.parent) if (e.matches(sel)) return e; return null; }
all(sel, out = []) { for (const c of this.children) { if (c.matches(sel)) out.push(c); c.all(sel, out); } return out; }
querySelector(sel) { return this.all(sel)[0] ?? null; }
contains(x) { for (let e = x; e; e = e.parent) if (e === this) return true; return false; }
get text() { return this.textContent + this.children.map((c) => c.text).join(""); }
click() { const event = { target: this }; for (let e = this; e; e = e.parent) (e.on.click ?? []).forEach((f) => f(event)); }
}
globalThis.document = { createElement: (t) => new E(t) };
globalThis.requestAnimationFrame = (f) => f();
"""
def run(files, js_body, prelude=""):
"""`files` 차례로 이어 붙인 소스(import 지움) + 가짜 DOM + `js_body` → stdout JSON."""
src = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=FAKE_DOM + STUBS + prelude + 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)
@@ -1,13 +1,11 @@
"""157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」.
- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 줄 객체 · 같은 차례
- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 표(A)
- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 값 · 같은 차례(171-3 공용 묶음 카드로 갈음)
- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본)
- 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip)
"""
import json
import re
import subprocess
import sys
from pathlib import Path
@@ -16,23 +14,13 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Sangeun import spec_text # noqa: E402
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # 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)
return run([COMMON, TABS, "B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", GROUPS], js_body)
def _block(key, no, spec="", spec_raw=""):
@@ -58,51 +46,45 @@ 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)) }));"
"\nconst cards = tabGroups('sg', t);"
"\nconst ats = cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at), c.foot.at]);"
"\nconsole.log(JSON.stringify({ n: t.rows.length, keys: cards.map((c) => c.key), ats,"
" sums: cards.flatMap((c) => c.lines.map((l) => [l.sum, t.rows[l.at].sum])),"
" heads: cards.map((c) => [c.no, c.name, c.unit, c.sum]),"
" links: cards.map((c) => c.lines.map((l) => (l.link ? 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]
assert out["ats"] == list(range(out["n"])) # 카드 줄 = 표 줄 번호 그대로 · 같은 차례
assert all(a == b for a, b in out["sums"])
assert out["heads"] == [["제 1 호표", "산근1", "m3", 8328], ["제 2 호표", "산근2", "m3", 8328]]
# 머리 줄 1 · 노무 1 · 중기 3 — 줄 누름 이동은 표와 같은 link
assert out["links"][0] == [None, "LB2", "EQ1", "EQ1", "EQ1"]
def test_157_2_단추_바꿈():
out = _node(
"\nconsole.log(JSON.stringify([sgViewLabel('table'), sgViewLabel('cards'),"
" flipSgView('table'), flipSgView(flipSgView('table'))]));"
"\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards'),"
" flipView('table'), flipView(flipView('table'))]));"
)
assert out == ["호표별 보기", "표로 보기", "cards", "table"]
def test_157_2_기억_실패_때_표():
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)]));"
"\nconst a = [readGroupView('sg', null), readGroupView('sg', { sg: 'x' }),"
" readGroupView('sg', { dg: 'table' }), readGroupView('sg', { sg: 'table' })];"
"\nconsole.log(JSON.stringify(a));"
)
assert out == ["table", "table", "table", "table", "cards", "cards"]
assert out == ["cards", "cards", "cards", "table"]
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 "isGroupTab(tab)" in shell and "renderGroupPanel(" in shell
assert not (
B09 / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts"
).exists() # 171-2 공용 양식으로 갈음
css = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8")
assert "white-space: pre-wrap" in css # 긴 산출내역 글 = 카드 안 줄바꿈
@@ -41,7 +41,7 @@ def test_엑셀_검산_시멘트_모래_자갈_레미콘():
def test_엑셀_손값과_다른_상수는_알림만():
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
assert "2공구 엑셀 손값과 다름" in ui and "b08MaterialExcel" in ui
assert "2공구 엑셀 수기 입력값과 다름" in ui and "b08MaterialExcel" in ui
# 기본 배합은 품셈 근거 그대로(124)
per = {r["key"]: r["per"] for r in compute(SUMMARY, clean_overrides({}))["concrete"]["rows"]}
assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612}
+1 -1
View File
@@ -177,7 +177,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
base = compute_base(prices.build_inputs(None, keys), {})
got = sg.collect([("s1", a)], base, sg.hand_sources(user))
listed = [(r["no"], r["logic"], r["uses"]) for r in got["sg_list"]]
assert listed == [(1, NET, ["s1", "손 줄 새 산근"]), (2, STONE, ["손 줄 메쌓기"])]
assert listed == [(1, NET, ["s1", "수기 줄 새 산근"]), (2, STONE, ["수기 줄 메쌓기"])]
assert got["sg_hand"] == {
"hand:c": got["sg_list"][1]["key"],
"hand:d": got["sg_list"][0]["key"],
@@ -0,0 +1,52 @@
"""171-1 「손입력 · 손 입력 · 손 줄」 → 「수기 입력 · 수기 줄」 화면 글 — 소스 시험.
화면 글(따옴표 안)만 바꿈 · 저장 키(`{손: 수}` · 수량출처 「손」 · 표 `손` 깃) · 주석 · 손료는 그대로.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SPOTS = {
"A00_Common/design/design_structure_ford_calc.ts": ["설계유량 없음 — 수기 입력"],
"B08_Quantity/B08_Quantity_UI_Material.ts": ["2공구 엑셀 수기 입력값과 다름"],
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
"ui_template/ui_template_locale_b2.ts": [
"수량 원천: 수기 입력(직접비)",
"수량 원천: 수기 입력 공종",
],
}
# 따옴표 안 화면 글에 남은 옛 이름(주석 · 문서 글 빼고)
OLD = re.compile(r"[\"'`][^\"'`\n]*(손 ?입력|손 줄|손값)")
NOTE = re.compile(r'"""[\s\S]*?"""|/\*[\s\S]*?\*/|^\s*(//|#).*$', re.M)
def _text(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_새_이름_자리():
for rel, wants in SPOTS.items():
src = _text(rel)
for w in wants:
assert w in src, f"{rel}: {w}"
def test_화면_글에_옛_이름_없음():
for rel in SPOTS:
for line in NOTE.sub("", _text(rel)).splitlines():
assert not OLD.search(line), f"{rel}: {line.strip()}"
def test_저장_키는_그대로():
calc = _text("M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts")
assert 'export type QtySource = "손" | "칸" | "없음";' in calc
assert "export type Qty = { 손: number } | null;" in calc
assert "손?: boolean;" in _text("ui_template/sheet/ui_template_sheet_types.ts")
assert "rows[] 손 줄" in _text("B09_Estimation/B09_Estimation_Calc_Bill.ts") # 주석 그대로
+154
View File
@@ -0,0 +1,154 @@
"""171-2 묶음 카드 공용 양식(`ui_template_group_cards`) — Node(타입 지움) + 작은 가짜 DOM.
- 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 이름 · 규격 · 수량 · 단위 · 합계 · 비고 / 합계 줄 = 노무 · 재료 · 경비 · 계
- 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 오른쪽 · 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음)
- 가로 열 수 1~4(기본 2 · 고르면 알림) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기)
- 157-2 산근 카드 파일 = 이 양식으로 갈음(지움)
"""
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, run # noqa: E402
GROUPS_JS = """
const mk = (n) => ({
key: `G${n}`, at: n * 10, no: `제 ${n} 호표`, name: `묶음${n}`, spec: "규격", specTip: "원문번호 1",
unit: "m3", sum: 1234.5,
lines: [
{ at: n * 10 + 1, name: "석공", spec: "", qty: 0.072, unit: "인", sum: 18643.3,
parts: [18643.3, null, null], note: "노무3", link: true },
{ at: n * 10 + 2, name: "인용", tag: "품셈" },
],
foot: { at: n * 10 + 3, parts: [100, 20, 3], sum: 123, note: "빈 줄 1", tip: "빈 줄 — 모래" },
});
const picks = [];
const cols = [];
const make = (count, extra = {}) => createGroupCards({
groups: Array.from({ length: count }, (_, i) => mk(i + 1)),
onPick: (at) => picks.push(at), onColumns: (n) => cols.push(n), ...extra,
});
"""
def _run(body):
return run([COMMON], GROUPS_JS + body)
def test_도우미_열수_요약_풍선():
out = _run(
"\nconsole.log(JSON.stringify({"
" cols: [0, 1, 2, 3, 4, 5, '3', null, undefined].map(clampColumns),"
" summary: [12, 13].map(isSummary),"
" tip: [partsTip([1000, 0, null]), partsTip([null, null, null]), partsTip(null)] }));"
)
assert out["cols"] == [2, 1, 2, 3, 4, 2, 2, 2, 2]
assert out["summary"] == [False, True]
assert out["tip"] == ["노무 1,000 · 재료 0 · 경비 -", None, None]
def test_카드_머리_몸_합계_줄():
out = _run(
"\nconst root = make(1);"
"\nconst box = root.querySelector('.ui-group-card');"
"\nconst head = box.querySelector('.ui-group-card__head');"
"\nconst tds = box.querySelector('tbody').children[0].children;"
"\nconst ths = box.querySelector('thead').children[0].children;"
"\nconsole.log(JSON.stringify({"
" head: head.children.map((c) => [c.className, c.textContent, c.title]),"
" ths: ths.map((c) => [c.textContent, c.className]),"
" tds: tds.map((c) => [c.textContent, c.className, c.title]),"
" tag: box.querySelector('.ui-group-card__tag').textContent,"
" foot: box.querySelector('.ui-group-card__foot').children.map((c) => c.textContent),"
" link: box.querySelector('tbody').children.map((r) => r.classList.contains('is-link')),"
" cols: root.querySelector('.ui-group-cards__grid').style.props['--group-cols'],"
" summary: root.all('.ui-btn').map((b) => b.textContent) }));"
)
assert out["head"] == [
["ui-group-card__no", "제 1 호표", ""],
["ui-group-card__title", "묶음1", ""],
["ui-group-card__spec", "규격", "원문번호 1"],
["ui-group-card__unit", "m3", ""],
["ui-group-card__total", "1,234.5", ""],
]
assert out["ths"] == [
["이름", ""], ["규격", ""], ["수량", "is-num"], ["단위", ""], ["합계", "is-num"], ["비고", ""],
] # fmt: skip
# 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 칸 = 오른쪽(is-num)
assert out["tds"] == [
["석공", "ui-group-card__name", ""],
["", "", ""],
["0.1", "is-num", ""],
["인", "", ""],
["18,643.3", "is-num", "노무 18,643.3 · 재료 - · 경비 -"],
["노무3", "ui-group-card__note", ""],
]
assert out["tag"] == "품셈"
assert out["foot"] == ["노무 100", "재료 20", "경비 3", "계 123", "빈 줄 1"]
assert out["link"] == [True, False]
assert out["cols"] == "2" # 기본 2
assert out["summary"] == ["1", "2", "3", "4"] # 12 이하 = 요약 단추 없음
def test_가로_열수_고름_알림_누름_이동():
out = _run(
"\nconst root = make(2, { columns: 3 });"
"\nconst grid = root.querySelector('.ui-group-cards__grid');"
"\nconst before = grid.style.props['--group-cols'];"
"\nroot.querySelector('[data-group-cols=\"4\"]').click();"
"\nroot.querySelector('[data-at=\"21\"]').click();"
"\nroot.querySelector('[data-at=\"20\"]').click();"
"\nconsole.log(JSON.stringify({ before, after: grid.style.props['--group-cols'], cols,"
" pressed: root.all('[data-group-cols]').map((b) => b.attrs['aria-pressed']), picks }));"
)
assert out["before"] == "3" and out["after"] == "4"
assert out["cols"] == [4]
assert out["pressed"] == ["false", "false", "false", "true"]
assert out["picks"] == [21, 20] # 줄 · 머리 누름 = 표 줄 번호 그대로(갈 곳은 부르는 쪽 link)
def test_묶음_12_넘으면_요약_보기():
out = _run(
"\nconst root = make(13, { focus: 52 });"
"\nconst cards = root.all('.ui-group-card');"
"\nconst closed = () => cards.map((c) => c.classList.contains('is-closed'));"
"\nconst first = closed();"
"\nconst focused = root.querySelector('[data-at=\"52\"]');"
"\ncards[0].querySelector('.ui-group-card__head').click();"
"\nconst opened = closed()[0];"
"\nconst btn = (label) => root.all('.ui-btn').find((b) => b.textContent === label);"
"\nbtn('모두 펼침').click(); const all = closed();"
"\nbtn('모두 접기').click(); const none = closed();"
"\nconsole.log(JSON.stringify({ first, opened, all, none, picks,"
" foot: cards.every((c) => c.querySelector('.ui-group-card__foot')),"
" focus: [focused.classList.contains('is-focus'), !!focused.scrolled] }));"
)
# 이동해 온 줄(5번째 묶음 줄) = 그 묶음만 펼침 · 강조 · 스크롤
assert out["first"] == [True] * 4 + [False] + [True] * 8
assert out["focus"] == [True, True]
assert out["opened"] is False # 머리 누름 = 펼침
assert out["picks"] == [] # 요약 보기 머리 누름은 이동 아님
assert out["all"] == [False] * 13 and out["none"] == [True] * 13
assert out["foot"] is True # 접어도 합계 줄은 보임(몸만 숨김)
def test_양식_글자_간격_숫자():
css = (ROOT / "ui_template" / "ui_template_group_cards.css").read_text("utf-8")
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
assert "--text-body-sm: 14px;" in theme
assert "font-size: var(--text-body-sm);" in css # 14px 한 벌
assert [ln.strip() for ln in css.splitlines() if "font-size" in ln] == [
"font-size: var(--text-body-sm);",
"font-size: inherit;",
]
assert "padding: 1px var(--spacing-4);" in css # 줄 간격 좁게
assert "spacing-16" not in css and "card-padding" not in css # 공용 카드 16px 끼움 없음
assert "grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));" in css
assert "text-align: right;" in css
src = (ROOT / COMMON).read_text("utf-8")
assert "createCard" not in src
assert not (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts").exists()
@@ -0,0 +1,176 @@
"""171-3 B09 묶음 탭 셋 = 묶음 카드 기본 · [표로 보기] = 표 그대로 · 보기 기억(탭마다).
- 일위대가표 · 중기사용료 · 단가산출근거 — 표 줄을 호표마다 묶음(값 · 풍선 · 누름 이동 = 표와 같음)
- 기억 = `b_page_state` 두 줄(`b09-group-view` · `b09-group-cols`) · 탭마다 따로 · 모르는 값 = 카드 · 2
- 집계표 넷 · 설계내역서 · B08 = 대상 밖
"""
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 resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
B09 = "B09_Estimation/B09_Estimation_UI_Tabs_"
DG = {
"id": "DG1", "no": 1, "name": "찰쌓기", "spec": "기계=굴착기 0.6㎥", "spec_raw": "기계=0201-0070",
"unit": "m2", "kind": "품셈", "cite": "건설표준품셈 7-1-2",
"lines": [
{"kind": "labor", "key": "L00005", "name": "석공", "qty": 0.072, "unit": "인",
"price": 258935, "amount": 18643.3, "ref_no": "노무3",
"prices": {"labor": 258935, "material": None, "expense": None},
"amounts": {"labor": 18643.3, "material": None, "expense": None}},
{"kind": "sangeun", "key": "SG1", "name": "뒷채움", "qty": 0.5, "unit": "m3",
"price": 8328, "amount": 4164, "ref_no": "산근 1호표",
"prices": {"labor": 6878, "material": 538, "expense": 912},
"amounts": {"labor": 3439, "material": 269, "expense": 456}},
],
"sum": 22807, "labor": 22082, "material": 269, "expense": 456,
"blank": 1, "blank_names": ["모래"],
} # fmt: skip
MB = {
"key": "EQ1", "name": "굴착기", "spec": "0.6㎥", "unit": "hr",
"lines": [
{"kind": "expense", "name": "손료", "spec": "", "qty": 1, "unit": "hr", "price": 13041,
"amount": 13041, "ref": None},
{"kind": "labor", "name": "운전사", "spec": "", "qty": 1, "unit": "인", "price": 55700,
"amount": 55700, "ref": {"key": "LB9"}},
],
"sum": 68741, "labor": 55700, "material": 0, "expense": 13041,
} # fmt: skip
SG = {
"key": "SG:A", "no": 1, "name": "뒷채움", "unit": "m3", "spec": "규격=150", "spec_raw": "규격=150",
"head": ["뒷채움 / m3", "산림품셈 13-3-2 적용"],
"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"]},
],
"labor": 2979, "material": 0, "expense": 0, "sum": 2979, "why": None, "foot": "계 : 2,979",
} # fmt: skip
# 카드 → 판정 거리(머리 · 몸 줄 · 합계 · 표 줄 번호 · 값이 표와 같은가 · 누름 이동)
DUMP = """
const dump = (tab, t) => {
const cards = tabGroups(tab, t);
const first = t.columns[0].key;
return {
n: t.rows.length,
heads: cards.map((c) => [c.no, c.name, c.spec, c.specTip, c.unit, c.sum]),
lines: cards.flatMap((c) => c.lines.map((l) => [l.at, l.tag ?? null, l.name, l.qty ?? null, l.sum, l.parts, l.note])),
foot: cards.map((c) => [c.foot.at, c.foot.parts, c.foot.sum, c.foot.note, c.foot.tip]),
ats: cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at), c.foot.at]),
same: cards.every((c) => c.lines.every((l) => l.sum === (t.rows[l.at].sum ?? null))),
links: cards.flatMap((c) => c.lines.map((l) => (l.link ? t.link(l.at, first) : null))),
};
};
"""
def _tab(files, build, sheets, tab):
body = f"\nconsole.log(JSON.stringify(dump('{tab}', {build}({{ sheets: {json.dumps(sheets, ensure_ascii=False)} }}))));"
prelude = "" if any("Sangeun" in f for f in files) else RAW # raw 짝 = 산근 파일에 있음
return run([COMMON, TABS, *files, GROUPS], DUMP + body, prelude)
def test_일위대가표_카드():
out = _tab([f"{B09}Labor.ts"], "buildDaegaBlocks", {"daega_blocks": [DG]}, "dg")
assert out["heads"] == [
["제 1 호표", "찰쌓기", "기계=굴착기 0.6㎥", "원문번호 기계=0201-0070", "m2", 22807]
]
assert out["lines"] == [
[1, None, "건설표준품셈 7-1-2", None, None, [None, None, None], "품셈 인용"],
[2, None, "석공", 0.072, 18643.3, [18643.3, None, None], "노무3"],
[3, None, "뒷채움", 0.5, 4164, [3439, 269, 456], "산근 1호표"],
]
assert out["foot"] == [[4, [22082, 269, 456], 22807, "빈 줄 1", "빈 줄 — 모래"]]
assert out["ats"] == list(range(out["n"])) and out["same"] is True
assert out["links"] == [None, {"tab": "labor", "key": "L00005"}, {"tab": "sg", "key": "SG1"}]
def test_중기사용료_카드():
out = _tab([f"{B09}Base.ts"], "buildMachineBlocks", {"machine_blocks": [MB]}, "machine_blocks")
assert out["heads"] == [["제 1 호표", "굴착기", "0.6㎥", None, "hr", 68741]]
assert out["lines"] == [
[1, None, "손료", 1, 13041, [None, None, 13041], ""],
[2, None, "운전사", 1, 55700, [55700, None, None], ""],
]
assert out["foot"] == [[3, [55700, 0, 13041], 68741, "", None]]
assert out["ats"] == list(range(out["n"])) and out["same"] is True
assert out["links"] == [None, {"tab": "basis", "key": "LB9"}]
def test_단가산출근거_카드():
out = _tab([f"{B09}Sangeun.ts"], "buildSg", {"sg": [SG]}, "sg")
assert out["heads"] == [["제 1 호표", "뒷채움", "규격=150", None, "m3", 2979]]
assert out["lines"] == [
[1, "", "산림품셈 13-3-2 적용", None, None, [None, None, None], ""],
[2, "노무", "보통인부 : 165,545 * 0.018 = 2,979.8", None, 2979.8, [2979.8, 0, 0], ""],
]
assert out["foot"][0][:3] == [3, [2979, 0, 0], 2979]
assert out["ats"] == list(range(out["n"])) and out["same"] is True
assert out["links"] == [None, {"tab": "labor", "key": "LB2"}]
def test_보기_기억_탭마다():
out = run(
[COMMON, TABS, GROUPS],
"\nconst a = [readGroupView('dg'), readGroupCols('dg')];"
"\nrememberGroup('b09-group-view', 'dg', 'table');"
"\nrememberGroup('b09-group-cols', 'sg', 3);"
"\nconst b = [readGroupView('dg'), readGroupView('sg'), readGroupView('machine_blocks'),"
" readGroupCols('sg'), readGroupCols('dg'), readGroupCols('dg', { dg: 9 })];"
"\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept),"
" tabs: GROUP_TABS, other: ['bill', 'sum_material', 'sum_labor', 'sum_expense',"
" 'sum_machine', 'dg_list'].map(isGroupTab) }));",
)
assert out["a"] == ["cards", 2] # 기본 = 카드 · 가로 2
assert out["b"] == ["table", "cards", "cards", 3, 2, 2]
assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"}
assert json.loads(out["kept"]["b09-group-cols"]) == {"sg": 3}
assert out["tabs"] == ["dg", "machine_blocks", "sg"]
assert out["other"] == [False] * 6 # 집계표 넷 · 설계내역서 · 목록표 = 대상 밖
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 o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: (l) => gone.push(l),"
" redraw: () => redraws++, makeTable: () => ({ root: document.createElement('table'),"
" focusRow: (i) => focused.push(i) }) };"
"\nconst [bar, view] = renderGroupPanel(o);"
"\nconst label = bar.children[0].textContent;"
"\nview.querySelector('[data-at=\"2\"]').click();"
"\nview.querySelector('[data-at=\"0\"]').click();"
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
"\nbar.children[0].click();"
"\nconst [bar2, view2] = renderGroupPanel(o);"
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, redraws,"
" label2: bar2.children[0].textContent, tag2: view2.tagName, focused,"
" kept: Object.fromEntries(kept) }));",
RAW,
)
assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드
assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동
assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조
assert out["redraws"] == 1
assert out["label2"] == "호표별 보기" and out["tag2"] == "TABLE" and out["focused"] == [3]
assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"}
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
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
b08 = "".join(p.read_text("utf-8") for p in (ROOT / "B08_Quantity").glob("*.ts"))
assert "createGroupCards" not in b08 # B08 = 대상 밖
@@ -100,7 +100,11 @@ def test_월류_높이는_폭과_연동_필요_수심_올림(out):
row = [h for q, b, h in out["table"] if q == 2.54]
assert row == sorted(row, reverse=True) and len(set(row)) == len(row)
assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력
assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"]
assert out["hints"] == [
"필요 수심 0.08 이상",
"월류 폭을 넣으면 셈",
"설계유량 없음 — 수기 입력",
]
def test_정의_파일에_세월교_이름_표가_올라_있음():
+153
View File
@@ -0,0 +1,153 @@
/* 묶음 카드 공용 양식(171-2) — 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽. 색은 테마 변수만. */
.ui-group-cards {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
line-height: 1.35;
}
.ui-group-cards__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.ui-group-cards__cols {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.ui-group-cards__label,
.ui-group-cards__count {
color: var(--color-text-secondary);
}
.ui-group-cards__actions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}
.ui-group-cards__grid {
display: grid;
grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));
align-items: start;
gap: var(--spacing-8);
}
.ui-group-card {
min-width: 0;
padding: var(--spacing-4) var(--spacing-8);
background-color: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.ui-group-card__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--spacing-8);
padding: 2px 0;
border-bottom: 1px solid var(--color-border);
font-weight: var(--font-weight-bold);
}
.ui-group-card__no {
color: var(--color-accent);
}
.ui-group-card__spec,
.ui-group-card__unit {
color: var(--color-text-secondary);
font-weight: normal;
}
.ui-group-card__total {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.ui-group-card__body {
overflow-x: auto;
}
.ui-group-card.is-closed .ui-group-card__body {
display: none;
}
.ui-group-card.is-closed .ui-group-card__head {
cursor: pointer;
}
.ui-group-card__lines {
width: 100%;
border-collapse: collapse;
font-size: inherit;
}
.ui-group-card__lines th,
.ui-group-card__lines td {
padding: 1px var(--spacing-4);
text-align: left;
vertical-align: top;
}
.ui-group-cards .ui-group-card__lines th {
color: var(--color-text-secondary);
font-weight: normal;
white-space: nowrap;
}
.ui-group-card__lines .is-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ui-group-card__name,
.ui-group-card__note {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.ui-group-card__note {
color: var(--color-text-secondary);
}
.ui-group-card__tag {
margin-right: var(--spacing-4);
color: var(--color-accent);
}
.ui-group-card__lines tr.is-link {
cursor: pointer;
}
.ui-group-card__lines tr.is-link:hover,
.ui-group-card .is-focus {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.ui-group-card__foot {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
column-gap: var(--spacing-8);
padding: 2px var(--spacing-4);
border-top: 1px solid var(--color-border);
font-variant-numeric: tabular-nums;
}
.ui-group-card__part,
.ui-group-card__foot-note {
color: var(--color-text-secondary);
}
.ui-group-card__sum {
font-weight: var(--font-weight-bold);
}
+249
View File
@@ -0,0 +1,249 @@
/* =============================================================================
* ui_template_group_cards.ts
* 묶음 카드 공용 양식(PLAN 171-2) — 여러 줄이 한 묶음인 엑셀 꼴 표(호표 블록)를 카드로.
*
* 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) /
* 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선)
* 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽)
* 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기)
* 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조
* ========================================================================== */
import "./ui_template_group_cards.css";
import { createButton } from "./ui_template_elements";
/** 비목 셋 — 노무 · 재료 · 경비 */
export type GroupParts = [number | null, number | null, number | null];
export interface GroupLine {
at: number;
name: string;
/** 이름 앞 작은 꼬리표(예 산근 공종 「노무 · 중기」) */
tag?: string;
spec?: string;
qty?: number | null;
unit?: string;
sum?: number | null;
parts?: GroupParts | null;
note?: string;
/** 규격 · 비고 칸 풍선(원문번호 · 까닭) */
specTip?: string | null;
noteTip?: string | null;
/** 누르면 이동하는 줄 */
link?: boolean;
}
export interface GroupFoot {
at: number;
parts: GroupParts;
sum: number | null;
note?: string;
tip?: string | null;
}
export interface GroupCard {
key: string;
at: number;
no: string;
name: string;
spec?: string;
specTip?: string | null;
unit?: string;
sum?: number | null;
lines: GroupLine[];
foot?: GroupFoot | null;
}
export interface GroupCardsOptions {
groups: GroupCard[];
/** 가로 열 수(1~4 · 모르는 값 = 2) */
columns?: unknown;
onColumns?: (columns: number) => void;
onPick?: (at: number) => void;
/** 이동해 온 줄(표 줄 번호) */
focus?: number;
money?: (value: number | null | undefined) => string;
qty?: (value: number | null | undefined) => string;
/** 머리 띠 끝 단추(예 [표로 보기]) */
actions?: HTMLElement[];
}
export const GROUP_SUMMARY_OVER = 12;
export const GROUP_COLUMNS = [1, 2, 3, 4] as const;
export const PART_LABELS = ["노무", "재료", "경비"] as const;
/** 가로 열 수 — 1~4 · 모르는 값 = 2 */
export const clampColumns = (value: unknown): number =>
GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2;
/** 요약 보기 — 묶음 12 넘음 */
export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER;
const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
const plain = (value: number | null | undefined): string =>
value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value);
/** 줄 비목 풍선 — 「노무 1,234 · 재료 0 · 경비 56」 · 셋 다 빈칸 = 없음 */
export function partsTip(
parts: GroupParts | null | undefined,
money: (value: number | null | undefined) => string = plain,
): string | null {
if (!parts || parts.every((v) => v === null || v === undefined)) return null;
return parts.map((v, i) => `${PART_LABELS[i]} ${money(v) || "-"}`).join(" · ");
}
function span(className: string, value: string, tip?: string | null): HTMLSpanElement {
const s = document.createElement("span");
s.className = className;
s.textContent = value;
if (tip) s.title = tip;
return s;
}
function cell(tag: "td" | "th", className: string, value: string, tip?: string | null) {
const c = document.createElement(tag);
c.className = className;
c.textContent = value;
if (tip) c.title = tip;
return c;
}
const COLS = ["이름", "규격", "수량", "단위", "합계", "비고"];
const NUM_COL = new Set([2, 4]);
function linesTable(g: GroupCard, o: Required<Pick<GroupCardsOptions, "money" | "qty">>) {
const table = document.createElement("table");
table.className = "ui-group-card__lines";
const head = document.createElement("tr");
head.append(...COLS.map((c, i) => cell("th", NUM_COL.has(i) ? "is-num" : "", c)));
const thead = document.createElement("thead");
thead.append(head);
const tbody = document.createElement("tbody");
for (const ln of g.lines) {
const tr = document.createElement("tr");
tr.dataset.at = String(ln.at);
if (ln.link) tr.classList.add("is-link");
const name = cell("td", "ui-group-card__name", ln.name);
if (ln.tag) name.prepend(span("ui-group-card__tag", ln.tag));
tr.append(
name,
cell("td", "", ln.spec ?? "", ln.specTip),
cell("td", "is-num", o.qty(ln.qty)),
cell("td", "", ln.unit ?? ""),
cell("td", "is-num", o.money(ln.sum), partsTip(ln.parts, o.money)),
cell("td", "ui-group-card__note", ln.note ?? "", ln.noteTip),
);
tbody.append(tr);
}
table.append(thead, tbody);
return table;
}
function footRow(f: GroupFoot, money: (v: number | null | undefined) => string): HTMLElement {
const foot = document.createElement("div");
foot.className = "ui-group-card__foot";
foot.dataset.at = String(f.at);
f.parts.forEach((v, i) =>
foot.append(span("ui-group-card__part", `${PART_LABELS[i]} ${money(v)}`)),
);
foot.append(span("ui-group-card__sum", `계 ${money(f.sum)}`, f.tip));
if (f.note) foot.append(span("ui-group-card__foot-note", f.note, f.tip));
return foot;
}
function card(g: GroupCard, o: Required<Pick<GroupCardsOptions, "money" | "qty">>): HTMLElement {
const box = document.createElement("article");
box.className = "ui-group-card";
box.dataset.key = g.key;
const head = document.createElement("div");
head.className = "ui-group-card__head";
head.dataset.at = String(g.at);
head.append(
span("ui-group-card__no", g.no),
span("ui-group-card__title", g.name),
span("ui-group-card__spec", g.spec ?? "", g.specTip),
span("ui-group-card__unit", g.unit ?? ""),
span("ui-group-card__total", o.money(g.sum ?? g.foot?.sum)),
);
const body = document.createElement("div");
body.className = "ui-group-card__body";
if (g.lines.length) body.append(linesTable(g, o));
box.append(head, body);
if (g.foot) box.append(footRow(g.foot, o.money));
return box;
}
/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */
export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain };
const root = document.createElement("div");
root.className = "ui-group-cards";
const grid = document.createElement("div");
grid.className = "ui-group-cards__grid";
let columns = clampColumns(opts.columns);
grid.style.setProperty("--group-cols", String(columns));
const cards = opts.groups.map((g) => card(g, fmt));
grid.append(...cards);
const summary = isSummary(opts.groups.length);
const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open);
if (summary) cards.forEach((c) => setOpen(c, false));
const bar = document.createElement("div");
bar.className = "ui-group-cards__bar";
const pick = document.createElement("div");
pick.className = "ui-btn-group ui-group-cards__cols";
pick.append(span("ui-group-cards__label", "가로"));
const colButtons = GROUP_COLUMNS.map((n) => {
const b = createButton({
label: String(n),
variant: n === columns ? "filled" : "ghost",
size: "sm",
onClick: () => {
columns = n;
grid.style.setProperty("--group-cols", String(n));
colButtons.forEach((x, i) => {
x.classList.toggle("ui-btn--filled", GROUP_COLUMNS[i] === n);
x.classList.toggle("ui-btn--ghost", GROUP_COLUMNS[i] !== n);
x.setAttribute("aria-pressed", String(GROUP_COLUMNS[i] === n));
});
opts.onColumns?.(n);
},
});
b.setAttribute("aria-pressed", String(n === columns));
b.dataset.groupCols = String(n);
return b;
});
pick.append(...colButtons);
bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`));
if (summary) {
const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open));
bar.append(
span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"),
createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }),
createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }),
);
}
const tail = document.createElement("div");
tail.className = "ui-group-cards__actions";
tail.append(...(opts.actions ?? []));
bar.append(tail);
root.append(bar, grid);
grid.addEventListener("click", (event) => {
const el = (event.target as Element).closest<HTMLElement>("[data-at]");
if (!el || !grid.contains(el)) return;
const box = el.closest(".ui-group-card");
if (summary && box && el.classList.contains("ui-group-card__head"))
return setOpen(box, box.classList.contains("is-closed"));
opts.onPick?.(Number(el.dataset.at));
});
if (opts.focus !== undefined && opts.focus >= 0) {
const el = grid.querySelector<HTMLElement>(`[data-at="${opts.focus}"]`);
const box = el?.closest(".ui-group-card");
if (box) setOpen(box, true);
el?.classList.add("is-focus");
requestAnimationFrame(() => el?.scrollIntoView({ block: "center" }));
}
return root;
}
+2 -2
View File
@@ -973,9 +973,9 @@ export const ui_locales_b2 = {
"공종별 수량 (한 줄에 「공종코드=수량」)",
'Quantities (one "code=qty" per line)',
],
B09_Estimation_Src_Manual: ["수량 원천: 손입력(직접비 직접 입력)", "Source: manual direct costs"],
B09_Estimation_Src_Manual: ["수량 원천: 수기 입력(직접비)", "Source: manual direct costs"],
B09_Estimation_Src_Quantities: [
"수량 원천: 손입력 공종 수량 × 일위대가",
"수량 원천: 수기 입력 공종 수량 × 일위대가",
"Source: manual quantities × unit prices",
],
B09_Estimation_Missing_UP: [