diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts
index 26490385e..73f6e7a43 100644
--- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts
+++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts
@@ -3,7 +3,8 @@
* B09 대가 탭 둘(PLAN 145-2) — 일위대가목록표 · 일위대가표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Labor`).
*
* 목록표 = 호표 한 줄(합계 · 노무비 · 재료비 · 경비) · 줄 누름 = 그 블록으로
- * 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록)
+ * 일위대가표 = 공용 일위대가 격자(704-3 · `ui_template_hopyo_grid` · 머리 · [품셈 인용] · 줄 · 합계 · 줄 꼬리표) ·
+ * 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록)
* 줄 꼴(155-2) = 머리 · 합계 굵게(`is-title`) · 인용 · 줄 = 들여쓰기 「ㄴ」(2단 · 겹 대가는 안 펼침)
* 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」
* 빈 줄(166-6) = 합계 = 아는 줄 합 · 옆 「빈 줄 n」 · 풍선 = 빈 줄 이름 · 내역 단가엔 안 넘김
@@ -14,7 +15,6 @@ import type { Row } from "./B09_Estimation_Calc_Base";
import {
blockLevel,
money,
- qty,
text,
type Link,
type TabBuild,
@@ -22,11 +22,10 @@ import {
type TabTable,
} from "./B09_Estimation_UI_Tabs";
import { raw } from "./B09_Estimation_UI_Tabs_Sangeun";
+import { HOPYO_TAG, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid";
const EMPTY =
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
-/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
-const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
const BLANK = "빈 줄은 금액을 모르는 줄(합계는 아는 줄만)";
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : "");
@@ -78,82 +77,48 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => {
export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
const blocks: Row[] = sheets.daega_blocks || [];
if (!blocks.length) return EMPTY;
- const rows: DataRow[] = [];
- const links: Array = [];
- const titles = new Set();
- for (const b of blocks) {
- titles.add(rows.length);
- rows.push({
+ // 704-3 — 공용 일위대가 격자(M02 카드 · 상세와 같은 열 · 줄 꼴) · 값 = 서버 `Calc_Labor` 그대로
+ const grid = hopyoGrid(
+ blocks.map((b) => ({
key: b.id,
- name: `제 ${b.no} 호표 ${b.name}`,
no: b.no,
- title: b.name, // 171-3 묶음 카드 머리
+ name: b.name,
spec: b.spec,
spec_raw: b.spec_raw,
unit: b.unit,
- note: b.kind,
- });
- links.push(null);
- if (b.cite) {
- rows.push({ key: `${b.id}|cite`, name: `${CHILD}${b.cite}`, note: "품셈 인용" });
- links.push(null);
- }
- b.lines.forEach((ln: Row, n: number) => {
- rows.push({
- key: `${b.id}|${n}`,
- name: `${CHILD}${ln.name || ""}`,
+ kind: b.kind,
+ cite: b.cite,
+ total: { sum: b.sum, labor: b.labor, material: b.material, expense: b.expense },
+ blank: b.blank ?? 0,
+ blank_names: b.blank_names ?? [],
+ lines: b.lines.map((ln: Row) => ({
+ name: ln.name || "",
spec: ln.spec || "",
spec_raw: ln.spec_raw,
qty: ln.qty,
unit: ln.unit || "",
- sum_price: ln.price,
- sum: ln.amount,
- labor_price: ln.prices.labor,
- labor: ln.amounts.labor,
- material_price: ln.prices.material,
- material: ln.amounts.material,
- expense_price: ln.prices.expense,
- expense: ln.amounts.expense,
+ prices: { sum: ln.price, ...ln.prices },
+ amounts: { sum: ln.amount, ...ln.amounts },
note: ln.ref_no || ln.why || "",
- });
- const tab = LINE_TAB[ln.kind];
- links.push(tab && ln.key ? { tab, key: String(ln.key) } : null);
- });
- titles.add(rows.length);
- rows.push({
- key: `${b.id}|합계`,
- name: "합 계",
- sum: b.sum,
- labor: b.labor,
- material: b.material,
- expense: b.expense,
- note: blankNote(b),
- blank: b.blank,
- blank_names: b.blank_names,
- });
- links.push(null);
- }
- const pair = (key: string, label: string) => [
- money(`${key}_price`, "단가", [label], 1),
- money(key, "금액", [label], 1),
- ];
+ tag: HOPYO_TAG.pum,
+ })),
+ })),
+ );
+ const { rows, titles } = grid;
+ const links: Array = rows.map((_, i) => {
+ const at = grid.at.get(i);
+ const ln: Row | undefined = at && blocks[at[0]].lines[at[1]];
+ const tab = ln && LINE_TAB[ln.kind];
+ return tab && ln.key ? { tab, key: String(ln.key) } : null;
+ });
return {
- columns: [
- text("name", "명칭", 240),
- text("spec", "규격", 160),
- qty("qty", "수량"),
- text("unit", "단위", 56),
- ...pair("sum", "합계"),
- ...pair("labor", "노무비"),
- ...pair("material", "재료비"),
- ...pair("expense", "경비"),
- text("note", "비고", 120),
- ],
+ columns: grid.columns,
rows,
frozen: 1,
hint: `줄을 누르면 단가를 낸 탭으로 이동 · ${BLANK}`,
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
+ cellClass: (i: number, key: string) => hopyoCellClass(rows, i, key),
level: blockLevel("name", (i) => titles.has(i)),
tip: (i: number, key: string) =>
key === "spec"
diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts
index 5b71c1bfe..dfdcf42fc 100644
--- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts
+++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts
@@ -13,6 +13,7 @@
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
+ * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만
* ========================================================================== */
import {
@@ -64,6 +65,7 @@ import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
+import { laborGrid } from "./M02_MasterTemplete_Labor_Grid";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
@@ -135,6 +137,7 @@ export function mountLaborDetail(
let pills = new Map();
const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved;
+ const gridHost = el("div", { attrs: { "data-labor-grid": "" } }); // 704-3 엑셀 꼴 격자(맨 위)
const logicHost = el("div");
const matHost = el("div");
const dgHost = el("div");
@@ -215,6 +218,7 @@ export function mountLaborDetail(
),
);
const draw = (): void => {
+ gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics));
drawLogic();
matHost.replaceChildren(
matTable(
@@ -642,8 +646,11 @@ export function mountLaborDetail(
}),
]),
// 사전조합 = 같은 [로직 추가](51-11)
- section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
- section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
+ section(tc("Grid_Title"), null, gridHost),
+ ...[
+ section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
+ section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
+ ].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만
...(opt.card || lib ? [] : [dgBox]),
],
}),
diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts
new file mode 100644
index 000000000..c2a4b7b9e
--- /dev/null
+++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts
@@ -0,0 +1,95 @@
+/* =============================================================================
+ * M02_MasterTemplete_Labor_Grid.ts
+ * 일위대가 엑셀 꼴 격자(704-3) — 문서 · 서버 calc 답 → 공용 격자(`ui_template_hopyo_grid` · B09 일위대가표와 같은 꼴).
+ * 일위대가 카드 · 상세(`Labor_Detail`)가 위에 붙임. 값은 calc 답 그대로(여기서 셈하지 않음):
+ * 머리 = 일위대가번 · 단위 · 1 단위 합계(`반영합`)
+ * 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈
+ * 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
+ * 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
+ * ========================================================================== */
+
+import {
+ createHopyoGrid,
+ HOPYO_TAG,
+ type HopyoBlock,
+ type HopyoLine,
+ type HopyoMoney,
+} from "@ui/ui_template_hopyo_grid";
+import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
+import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
+import { lineId } from "./M02_MasterTemplete_Labor_Calc";
+import { tc } from "./M02_MasterTemplete_Labor_Text";
+import { valueLine } from "./M02_Labor_Values_Model";
+
+const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
+ m
+ ? {
+ sum: m.계 / times,
+ labor: m.노무비 / times,
+ material: m.재료비 / times,
+ expense: m.경비 / times,
+ }
+ : {};
+
+/** 문서 + calc 답 → 호표 하나 — calc 없으면(계산 중) 금액 빈 칸 · 빈 줄 0 */
+export function laborBlock(
+ doc: LaborDoc,
+ calc: LaborCalc | null,
+ logics: ReadonlyMap,
+): HopyoBlock {
+ const logicLines = doc.담은로직.map((row): HopyoLine => {
+ const id = lineId(row) ?? "";
+ const line = calc?.줄.find((l) => lineId(l) === id);
+ const logic = logics.get(row.로직);
+ const layer =
+ row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
+ const qty = line?.수량 ?? null;
+ return {
+ name: row.별칭 || ((row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직),
+ spec: [valueLine(logic?.입력 ?? [], row.입력값).text, layer].filter(Boolean).join(" · "),
+ qty,
+ unit: line?.분모 ?? (row.참조 ? "" : (logic?.결과단위 ?? "")),
+ prices: money(line?.기준, line?.배 || 1),
+ amounts: money(line?.반영),
+ note: line?.까닭 ?? (calc && qty === null ? tc("Grid_NoQty") : (row.참조?.키 ?? row.로직)),
+ tag: HOPYO_TAG.pum,
+ };
+ });
+ const matLines = doc.자재.map((mat): HopyoLine => {
+ const line = calc?.자재.find((m) => m.코드 === mat.코드);
+ const qty = line?.수량 ?? null;
+ const price = line?.기준단가 ?? null;
+ const cost = line?.반영 ?? null;
+ return {
+ name: mat.이름 ?? mat.코드,
+ spec: mat.규격 ?? "",
+ qty,
+ unit: mat.단위 ?? "",
+ prices: { sum: price, material: price },
+ amounts: { sum: cost, material: cost },
+ note:
+ line?.까닭 ??
+ (calc && qty === null
+ ? tc("Grid_NoQty")
+ : mat.별도계상 !== "직접계산"
+ ? tc("Grid_Apart") // 짝 `settle` 의 aside 와 같은 규칙
+ : mat.코드),
+ };
+ });
+ const lines = [...logicLines, ...matLines];
+ return {
+ key: doc.열,
+ name: doc.일위대가번 || doc.열,
+ unit: doc.단위 ?? "",
+ total: money(calc?.반영합),
+ lines,
+ ...(calc ? {} : { blank: 0 }),
+ };
+}
+
+/** 격자 하나(읽기 전용) */
+export const laborGrid = (
+ doc: LaborDoc,
+ calc: LaborCalc | null,
+ logics: ReadonlyMap,
+): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root;
diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts
index 35792580c..17a29f798 100644
--- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts
+++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts
@@ -173,6 +173,10 @@ const TEXT = {
Lib_Base: ["기준 금액", "Base amount"],
Lib_Date: ["고친 날", "Modified"],
// 서고 구분 · 연결(51-12)
+ // 엑셀 꼴 격자(704-3)
+ Grid_Title: ["일위대가표", "Unit-price sheet"],
+ Grid_NoQty: ["수량 없음", "No quantity"],
+ Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"],
} as const satisfies Record;
export type ComboTextKey = keyof typeof TEXT;
diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md
index 33e1bf0ce..b7798c3b9 100644
--- a/docs/raw/요구_1025/사용자요구_1025.md
+++ b/docs/raw/요구_1025/사용자요구_1025.md
@@ -1009,3 +1009,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에
## 요구 510 (10-31 · 사용자)
랩탑의 저전력코어를 더사용하면? 어떻게 되는거야? / 6개를 사용하면 CPU 사용량은? / 위 내용 확인했음. 6개로 가도 문제 없어보임.
+
+## 요구 511 (10-31 · 사용자)
+
+저전력 코어는 그냥 두는게 좋겠어. 사용에 큰 문제가 없네. 이제부터 한시간에 한번 전체 계산해야하는 총량 대비 진행된 계산량을 보여줘.테이블하나로 표현하고 다른 미사여구는 필요없음.
diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py
index 2e74ef513..f431d252e 100644
--- a/resources/tester/helper_b09_group_cards.py
+++ b/resources/tester/helper_b09_group_cards.py
@@ -14,6 +14,7 @@ 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"
+GRID = "ui_template/ui_template_hopyo_grid.ts" # 704-3 일위대가표 격자(Tabs_Labor 앞)
# 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝
STUBS = r"""
diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py
index a59f43219..462abae0a 100644
--- a/resources/tester/test_155_b09_tabs.py
+++ b/resources/tester/test_155_b09_tabs.py
@@ -35,10 +35,11 @@ BLOCK = {
def _node(js_body, tab="UI_Tabs_Labor"):
- src = "".join(
+ grid = IMPORTS.sub("", (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text("utf-8"))
+ src = grid + "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("UI_Tabs", tab)
- )
+ ) # 704-3 일위대가표 = 공용 격자
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + js_body],
diff --git a/resources/tester/test_157_4_b09_labor_spec.py b/resources/tester/test_157_4_b09_labor_spec.py
index 24f7a1f40..3e02ba46d 100644
--- a/resources/tester/test_157_4_b09_labor_spec.py
+++ b/resources/tester/test_157_4_b09_labor_spec.py
@@ -57,9 +57,14 @@ def test_규격_칸_풍선():
"labor": 1, "material": 0, "expense": 0, "sum": 1}],
} # fmt: skip
sangeun = (B09 / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
- src = re.search(r"^export const raw =.*?;$", sangeun, re.MULTILINE | re.DOTALL)[0] + "".join(
- IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8"))
- for n in ("UI_Tabs", "UI_Tabs_Labor")
+ grid = IMPORTS.sub("", (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text("utf-8"))
+ src = (
+ grid
+ + re.search(r"^export const raw =.*?;$", sangeun, re.MULTILINE | re.DOTALL)[0]
+ + "".join(
+ IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8"))
+ for n in ("UI_Tabs", "UI_Tabs_Labor")
+ )
) # 산근 탭의 풍선 함수 `raw` 만(같은 이름 상수가 겹쳐 파일째는 못 잇음)
body = f"""
const sheets = {json.dumps(sheets, ensure_ascii=False)};
diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py
index 5e6f376f9..413f4cb3c 100644
--- a/resources/tester/test_201_1_table_level_merge.py
+++ b/resources/tester/test_201_1_table_level_merge.py
@@ -131,7 +131,10 @@ def test_B08_B09_적용():
mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8")
assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat
labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
- assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor
+ grid = (ROOT / "ui_template/ui_template_hopyo_grid.ts").read_text(
+ "utf-8"
+ ) # 704-3 줄 글 = 공용 격자
+ assert 'HOPYO_CHILD = "ㄴ "' in grid and '" 합 계"' not in labor + grid
# 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림)
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert ".cellIndex" not in shell and shell.count("cellColumn(") >= 2
diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py
index 7d33b35ce..a953cf527 100644
--- a/resources/tester/test_202_1_table_hint_bottom.py
+++ b/resources/tester/test_202_1_table_hint_bottom.py
@@ -15,7 +15,7 @@ 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
+from resources.tester.helper_b09_group_cards import COMMON, GRID, GROUPS, RAW, TABS, run # noqa: E402
B09 = "B09_Estimation/B09_Estimation_UI_"
BAR = f"{B09}TabBar.ts"
@@ -74,7 +74,7 @@ console.log(JSON.stringify({
def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
"""쉘이 하는 대로 — 줄 그림 → 묶음 판 그림(`setTools` = 보조 줄 도구) → 단추는 줄에, 판에는 없음."""
out = run(
- [COMMON, TABS, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
+ [COMMON, TABS, GRID, f"{B09}Tabs_Labor.ts", GROUPS, BAR],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
"\nconst bars = createTabBars(() => {});"
"\nconst hint = document.createElement('p');"
diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py
index 53266f234..becce2294 100644
--- a/resources/tester/test_304_b09_view_setting.py
+++ b/resources/tester/test_304_b09_view_setting.py
@@ -14,7 +14,7 @@ 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
+from resources.tester.helper_b09_group_cards import COMMON, GRID, GROUPS, RAW, TABS, run # noqa: E402
LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts"
DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts"
@@ -33,7 +33,7 @@ SPEC = (
def test_한_설정_셋_탭_함께():
out = run(
- [COMMON, TABS, LABOR, GROUPS],
+ [COMMON, TABS, GRID, LABOR, GROUPS],
SPEC + "\nconst a = kinds();"
"\nchooseGroupView('table');"
"\nconst b = kinds();"
@@ -59,7 +59,7 @@ def test_옛_탭별_값_첫_읽기에_한_값으로():
]
for old, want in cases:
out = run(
- [COMMON, TABS, LABOR, GROUPS],
+ [COMMON, TABS, GRID, LABOR, GROUPS],
"\nkept.set('b09-group-view', "
+ json.dumps(json.dumps(old))
+ ");"
diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py
new file mode 100644
index 000000000..bb9b3e521
--- /dev/null
+++ b/resources/tester/test_704_3_hopyo_grid.py
@@ -0,0 +1,142 @@
+"""704-3 일위대가 엑셀 꼴 격자 — 공용 부품 `ui_template_hopyo_grid` 줄 꼴 · M02 문서 + calc → 격자 줄 값.
+
+TS 소스를 이어 붙여 Node(타입 지움)로 돌림 — 값은 받은 그대로(단가 = 기준 ÷ 배 · 금액 = 반영 · 합계 = 반영합).
+"""
+
+import json
+import re
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
+GRID = "ui_template/ui_template_hopyo_grid.ts"
+
+
+def _node(files, body, prelude=""):
+ 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=prelude + src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
+ ) # fmt: skip
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+LINE = {"name": "돌쌓기", "spec": "뒷길이 35cm", "qty": 2.09, "unit": "㎡", "tag": "품셈",
+ "prices": {"sum": 52995, "labor": 41250, "expense": 11745},
+ "amounts": {"sum": 110759.6, "labor": 86212.5, "expense": 24547.1}} # fmt: skip
+
+
+def test_머리_구성_합계_줄_꼬리표_빈_줄():
+ blocks = [
+ {"key": "A", "no": 3, "name": "돌기슭막이", "unit": "m", "cite": "산림품셈 13-2",
+ "total": {"sum": 110759.6, "labor": 86212.5}, "lines": [LINE, {**LINE, "name": "잡재료",
+ "qty": None, "prices": {}, "amounts": {}}]},
+ {"key": "B", "name": "번호 없음", "unit": "식", "total": {}, "lines": [], "blank": 2},
+ ] # fmt: skip
+ out = _node([GRID], f"\nconst g = hopyoGrid({json.dumps(blocks, ensure_ascii=False)});"
+ "\nconst cls = g.rows.map((_, i) => hopyoCellClass(g.rows, i, 'tag') ?? hopyoCellClass(g.rows, i, 'note'));"
+ "\nconsole.log(JSON.stringify({ rows: g.rows, titles: [...g.titles], at: [...g.at], cls,"
+ " cols: g.columns.map((c) => c.key) }));") # fmt: skip
+ rows = out["rows"]
+ assert [r["name"] for r in rows] == [
+ "제 3 호표 돌기슭막이", "ㄴ 산림품셈 13-2", "ㄴ 돌쌓기", "ㄴ 잡재료", "합 계", "번호 없음", "합 계",
+ ] # fmt: skip
+ assert rows[0]["sum"] == 110759.6 # 머리 = 1 단위 합계
+ assert (
+ rows[2]["labor_price"] == 41250 and rows[2]["labor"] == 86212.5 and rows[2]["qty"] == 2.09
+ )
+ assert rows[2]["material_price"] is None and rows[2]["tag"] == "품셈"
+ assert rows[4]["note"] == "빈 줄 1" and rows[4]["blank_names"] == [
+ "잡재료"
+ ] # 금액 모르는 줄에서 셈
+ assert rows[6]["note"] == "빈 줄 2" # 준 빈 줄 수 그대로
+ assert out["titles"] == [0, 4, 5, 6] and out["at"] == [[2, [0, 0]], [3, [0, 1]]]
+ assert out["cls"][2] == "ui-hopyo-grid__tag is-pum" and out["cls"][4] == "ui-hopyo-grid__blank"
+ assert out["cols"][-1] == "tag" and out["cols"][:4] == ["name", "spec", "qty", "unit"]
+
+
+STUBS = r"""
+const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%" })[k] ?? k;
+const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 });
+"""
+
+DOC = {
+ "종류": "일위대가",
+ "열": "C1",
+ "일위대가번": "SU-000006",
+ "단위": "m",
+ "담은로직": [
+ {"로직": "GF1", "별칭": None, "입력값": {"뒷길이": "35cm"}, "수량": None, "켜쌓기율": 90},
+ {"로직": "GF2", "별칭": "고임돌", "입력값": {}, "수량": None},
+ ],
+ "자재": [
+ {"코드": "MT1", "이름": "큰돌", "규격": "30cm", "단위": "개", "수량": None},
+ {
+ "코드": "MT2",
+ "이름": "철선",
+ "규격": "",
+ "단위": "kg",
+ "수량": None,
+ "별도계상": "직접계산",
+ },
+ ],
+}
+CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
+ "기준": {"노무비": 412500, "재료비": 0, "경비": 117450, "계": 529950},
+ "반영": {"노무비": 86212.5, "재료비": 0, "경비": 24547.05, "계": 110759.55}},
+ {"로직": "GF2", "분모": "㎥", "배": 1, "수량": None,
+ "기준": {"노무비": 24816, "재료비": 0, "경비": 0, "계": 24816}, "반영": None}],
+ "자재": [{"코드": "MT1", "기준단가": 1200, "수량": 5, "반영": 6000},
+ {"코드": "MT2", "기준단가": 900, "수량": 2, "반영": 1800}],
+ "반영합": {"노무비": 86212.5, "재료비": 1800, "경비": 24547.05, "계": 112559.55}} # fmt: skip
+
+
+def test_M02_문서_calc_를_격자_줄로():
+ files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
+ "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
+ logics = [
+ ["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
+ ["GF2", {"이름": "고임돌 채집"}],
+ ]
+ out = _node(files, (
+ f"\nconst b = laborBlock({json.dumps(DOC, ensure_ascii=False)}, {json.dumps(CALC, ensure_ascii=False)},"
+ f" new Map({json.dumps(logics, ensure_ascii=False)}));"
+ "\nconst wait = laborBlock(" + json.dumps(DOC, ensure_ascii=False) + ", null, new Map());"
+ "\nconsole.log(JSON.stringify({ b, foot: hopyoGrid([b]).rows.at(-1), wait: hopyoGrid([wait]).rows.at(-1) }));"
+ ), STUBS) # fmt: skip
+ b = out["b"]
+ assert b["name"] == "SU-000006" and b["unit"] == "m" and b["total"]["sum"] == 112559.55
+ one, two, mt1, mt2 = b["lines"]
+ assert (
+ one["name"] == "돌쌓기 찰쌓기"
+ and one["spec"] == "35cm · 켜쌓기 90%"
+ and one["unit"] == "㎡"
+ )
+ assert one["prices"] == {
+ "sum": 52995,
+ "labor": 41250,
+ "material": 0,
+ "expense": 11745,
+ } # 기준 ÷ 배
+ assert one["amounts"]["sum"] == 110759.55 and one["tag"] == "품셈"
+ assert two["name"] == "고임돌" and two["qty"] is None and two["note"] == "수량 없음"
+ assert mt1["prices"] == {"sum": 1200, "material": 1200} and mt1["note"] == "별도계상 · 합계 밖"
+ assert (
+ mt2["amounts"] == {"sum": 1800, "material": 1800}
+ and mt2["note"] == "MT2"
+ and "tag" not in mt2
+ )
+ assert out["foot"]["note"] == "빈 줄 1" and out["foot"]["blank_names"] == ["고임돌"]
+ assert out["wait"]["note"] == "" # 계산 중 = 빈 줄 알림 없음
+
+
+def test_세_곳_같은_부품():
+ detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
+ assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail
+ m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
+ b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
+ assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
+ kit = (ROOT / "ui_template/ui_template_ui_kit_parts.ts").read_text("utf-8")
+ assert 'key: "hopyo-grid"' in kit # 부품 모음 견본
diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py
index f7449a623..3aca5dae7 100644
--- a/resources/tester/test_m02_hopyo_screen.py
+++ b/resources/tester/test_m02_hopyo_screen.py
@@ -360,7 +360,9 @@ def test_좌측_판_자리와_권한():
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
- assert len(detail.splitlines()) == 693 # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2)
+ assert (
+ len(detail.splitlines()) == 700
+ ) # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2 · 704-3 격자 +7)
assert "withHopyo(library)," in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise;" in pick and "...later" in pick
diff --git a/ui_template/ui_template_hopyo_grid.css b/ui_template/ui_template_hopyo_grid.css
new file mode 100644
index 000000000..f31819c12
--- /dev/null
+++ b/ui_template/ui_template_hopyo_grid.css
@@ -0,0 +1,21 @@
+/* 일위대가 엑셀 꼴 격자(PLAN 704-3) — 줄 꼬리표 · 빈 줄 알림만(표 꼴은 공용 데이터표) */
+
+.ui-data-table__table tbody td.ui-hopyo-grid__tag {
+ text-align: center;
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-bold);
+}
+
+.ui-data-table__table tbody td.ui-hopyo-grid__tag.is-pum {
+ color: var(--color-accent);
+}
+
+.ui-data-table__table tbody td.ui-hopyo-grid__tag.is-fix {
+ color: var(--color-warning);
+}
+
+/* 합계 줄(is-title) 안에서도 붉게 */
+.ui-data-table__table tbody tr td.ui-hopyo-grid__blank.ui-hopyo-grid__blank {
+ color: var(--color-danger);
+ font-weight: var(--font-weight-bold);
+}
diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts
new file mode 100644
index 000000000..af7320113
--- /dev/null
+++ b/ui_template/ui_template_hopyo_grid.ts
@@ -0,0 +1,223 @@
+/* =============================================================================
+ * ui_template_hopyo_grid.ts
+ * 일위대가 엑셀 꼴 격자(PLAN 704-3) — 호표마다 머리 줄 + 구성 줄 + 합계 줄 · 공용 데이터표(`ui_template_data_table`) 위.
+ * M02 일위대가 카드 · 상세 · B09 일위대가표(호표보기)가 같은 열 · 같은 줄 꼴을 씀.
+ *
+ * 머리 줄 = 「제 N 호표 이름」(번호 없으면 이름만) · 규격 · 단위 · 1 단위 합계(합계 금액 칸) · 비고 = 호표 꼴
+ * 품셈 인용 줄(있으면) = 「ㄴ 조항」 · 비고 「품셈 인용」
+ * 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
+ * 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
+ * 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
+ * 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
+ *
+ * const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
+ * B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀
+ * ========================================================================== */
+
+import {
+ createDataTable,
+ type DataColumn,
+ type DataRow,
+ type DataTableOptions,
+} from "./ui_template_data_table";
+import "./ui_template_hopyo_grid.css";
+
+export interface HopyoMoney {
+ sum?: number | null;
+ labor?: number | null;
+ material?: number | null;
+ expense?: number | null;
+}
+
+export interface HopyoLine {
+ name: string;
+ spec?: string | null;
+ /** 규격 원문(풍선 · 부르는 쪽 몫) */
+ spec_raw?: unknown;
+ /** 품 · 수량(1 단위 당) — null = 수량 없음 */
+ qty: number | null;
+ unit?: string | null;
+ /** 1 단위(위 단위) 당 단가 */
+ prices: HopyoMoney;
+ /** 단가 × 품/수량 — sum null = 금액 모름(빈 줄) */
+ amounts: HopyoMoney;
+ note?: string | null;
+ /** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */
+ tag?: string | null;
+}
+
+export interface HopyoBlock {
+ /** 줄 키 앞머리(머리 = key · 줄 = key|n · 합계 = key|합계) */
+ key: string;
+ no?: string | number | null;
+ name: string;
+ spec?: string | null;
+ spec_raw?: unknown;
+ unit?: string | null;
+ /** 호표 꼴(머리 줄 비고) */
+ kind?: string | null;
+ /** 품셈 인용(조항) */
+ cite?: string | null;
+ total: HopyoMoney;
+ lines: HopyoLine[];
+ /** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */
+ blank?: number;
+ blank_names?: string[];
+}
+
+/** 줄 꼬리표 낱말 */
+export const HOPYO_TAG = { pum: "품셈", fix: "고정" } as const;
+
+/** 하위 줄 앞 — 공용 표가 앞 빈칸을 접지 않음(들여쓰기 = level) */
+export const HOPYO_CHILD = "ㄴ ";
+
+const HOPYO_COSTS = [
+ ["sum", "합계"],
+ ["labor", "노무비"],
+ ["material", "재료비"],
+ ["expense", "경비"],
+] as const;
+
+const hopyoText = (key: string, label: string, width?: number): DataColumn => ({
+ key,
+ label,
+ width,
+});
+const hopyoMoney = (key: string, label: string, group: string): DataColumn => ({
+ key,
+ label,
+ group: [group],
+ kind: "number",
+ decimals: 1,
+ width: 96,
+});
+
+/** 열 — 명칭 · 규격 · 수량 · 단위 · 비목 넷(단가 / 금액) · 비고 · 구분 */
+export const HOPYO_COLUMNS: DataColumn[] = [
+ hopyoText("name", "명칭", 240),
+ hopyoText("spec", "규격", 160),
+ { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 76 },
+ hopyoText("unit", "단위", 56),
+ ...HOPYO_COSTS.flatMap(([key, label]) => [
+ hopyoMoney(`${key}_price`, "단가", label),
+ hopyoMoney(key, "금액", label),
+ ]),
+ hopyoText("note", "비고", 120),
+ hopyoText("tag", "구분", 56),
+];
+
+export interface HopyoGrid {
+ columns: DataColumn[];
+ rows: DataRow[];
+ /** 머리 · 합계 줄 번호(굵게 · 레벨 1) */
+ titles: Set;
+ /** 줄 번호 → [호표 차례, 구성 줄 차례] · 구성 줄만 */
+ at: Map;
+}
+
+const hopyoBlank = (b: HopyoBlock): { n: number; names: string[] } => {
+ if (b.blank !== undefined) return { n: b.blank, names: b.blank_names ?? [] };
+ const names = b.lines.filter((l) => l.amounts.sum == null).map((l) => l.name);
+ return { n: names.length, names };
+};
+
+/** 호표 → 표 줄(값 그대로) */
+export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
+ const rows: DataRow[] = [];
+ const titles = new Set();
+ const at = new Map();
+ blocks.forEach((b, bi) => {
+ titles.add(rows.length);
+ rows.push({
+ key: b.key,
+ name: b.no != null && b.no !== "" ? `제 ${b.no} 호표 ${b.name}` : b.name,
+ no: b.no,
+ title: b.name,
+ spec: b.spec ?? "",
+ spec_raw: b.spec_raw,
+ unit: b.unit ?? "",
+ sum: b.total.sum ?? null, // 1 단위 합계
+ note: b.kind ?? "",
+ });
+ if (b.cite)
+ rows.push({ key: `${b.key}|cite`, name: `${HOPYO_CHILD}${b.cite}`, note: "품셈 인용" });
+ b.lines.forEach((ln, n) => {
+ at.set(rows.length, [bi, n]);
+ const row: DataRow = {
+ key: `${b.key}|${n}`,
+ name: `${HOPYO_CHILD}${ln.name}`,
+ spec: ln.spec ?? "",
+ spec_raw: ln.spec_raw,
+ qty: ln.qty,
+ unit: ln.unit ?? "",
+ note: ln.note ?? "",
+ tag: ln.tag ?? "",
+ };
+ for (const [key] of HOPYO_COSTS) {
+ row[`${key}_price`] = ln.prices[key] ?? null;
+ row[key] = ln.amounts[key] ?? null;
+ }
+ rows.push(row);
+ });
+ const blank = hopyoBlank(b);
+ titles.add(rows.length);
+ rows.push({
+ key: `${b.key}|합계`,
+ name: "합 계",
+ sum: b.total.sum ?? null,
+ labor: b.total.labor ?? null,
+ material: b.total.material ?? null,
+ expense: b.total.expense ?? null,
+ note: blank.n ? `빈 줄 ${blank.n}` : "",
+ blank: blank.n,
+ blank_names: blank.names,
+ });
+ });
+ return { columns: HOPYO_COLUMNS, rows, titles, at };
+}
+
+/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */
+export function hopyoCellClass(rows: DataRow[], i: number, key: string): string | null {
+ const r = rows[i];
+ if (key === "tag" && r?.tag)
+ return `ui-hopyo-grid__tag${r.tag === HOPYO_TAG.fix ? " is-fix" : " is-pum"}`;
+ if (key === "note" && Number(r?.blank) > 0) return "ui-hopyo-grid__blank";
+ return null;
+}
+
+/** 공용 표 옵션(격자 꼴 그대로) — `extra` 로 누름 · 풍선 자리를 더함 */
+export function hopyoTableOptions(
+ blocks: HopyoBlock[],
+ extra: Partial = {},
+): DataTableOptions {
+ const g = hopyoGrid(blocks);
+ return {
+ columns: g.columns,
+ rows: g.rows,
+ frozen: 1,
+ empty: "",
+ rowClass: (i) => (g.titles.has(i) ? "is-title" : null),
+ cellClass: (i, key) => hopyoCellClass(g.rows, i, key),
+ level: { key: "name", of: (i) => (g.titles.has(i) ? 1 : 2) },
+ ...extra,
+ };
+}
+
+export interface HopyoGridHandle {
+ root: HTMLElement;
+ set: (blocks: HopyoBlock[]) => void;
+}
+
+/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */
+export function createHopyoGrid(
+ blocks: HopyoBlock[],
+ extra: Partial = {},
+): HopyoGridHandle {
+ const root = document.createElement("div");
+ root.className = "ui-hopyo-grid";
+ const set = (next: HopyoBlock[]): void => {
+ root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root);
+ };
+ set(blocks);
+ return { root, set };
+}
diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts
index afb3a99e8..4477ed974 100644
--- a/ui_template/ui_template_ui_kit_parts.ts
+++ b/ui_template/ui_template_ui_kit_parts.ts
@@ -40,6 +40,7 @@ import { createCard } from "./ui_template_card";
import { createCollapsibleHead, createPanelCard } from "./ui_template_collapsible";
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "./ui_template_calc_row";
import { createDataTable } from "./ui_template_data_table";
+import { createHopyoGrid } from "./ui_template_hopyo_grid";
import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart";
import { createEmpty } from "./ui_template_empty";
import { createLegend } from "./ui_template_flow_legend";
@@ -337,6 +338,7 @@ export const KIT_PARTS: KitPart[] = [
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
demo: (s) => (s === "default" ? kitCalc() : null) },
+ { key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);
const corner = createProgressCircle({ size: 40, corner: true }); corner.set(0.65, "지형 65%"); const box = document.createElement("div"); box.style.cssText = "position:relative;height:72px;border:1px dashed var(--color-border);border-radius:var(--radius-inputs)"; box.append(corner.root);