Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1
This commit is contained in:
@@ -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 ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력";
|
||||
}
|
||||
|
||||
@@ -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` 그대로) */
|
||||
|
||||
@@ -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 = "새 산근 — [저장] 뒤 단가(서버 셈)"
|
||||
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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") # 주석 그대로
|
||||
@@ -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_정의_파일에_세월교_이름_표가_올라_있음():
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user