feat(M02): 구조물 항목 목록 부품 — M01 찾기 줄 · 쪽 · 표 꼴 · 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 서버 목록 칸 수정일 · labor(번호 · 담은 로직 수 · 합계)

39-5 H1 · H6 — 전체 항목 · 복사 생성 · 항목 하나 복사가 같이 쓸 목록 하나.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-04 17:24:00 +09:00
co-authored by Claude Opus 5.5
parent fe07dd8a45
commit 18a0639253
5 changed files with 387 additions and 2 deletions
@@ -0,0 +1,182 @@
/* =============================================================================
* M02_MasterTemplete_ItemList.ts
* 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나
* 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
* (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음).
* 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다.
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
* ========================================================================== */
import { createInputField, el } from "@ui/ui_template_elements";
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
import "../M01_MasterData/M01_MasterData_UI_Style.css";
import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch";
import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model";
/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */
export type ListKind = "structure" | "area" | "labor" | "item";
/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */
export interface ItemRow extends TreeItem {
도번?: string | null;
면적번?: string | null;
수정일?: Partial<Record<"structure" | "area" | "labor", string>>;
labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null;
}
export interface Col<R> {
head: string;
get: (row: R) => string;
}
const TEXT = {
search: "찾기",
none: "항목이 없음",
noMatch: "맞는 항목이 없음",
loading: "불러오는 중",
};
const SIZE = 30;
const DASH = "—";
const day = (iso?: string): string => (iso ? iso.slice(0, 10) : "");
const money = (value: number | null | undefined): string =>
typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH;
/** 가장 늦게 고친 날 — 항목 통째 */
const latest = (row: ItemRow): string =>
day(
Object.values(row.수정일 ?? {})
.sort()
.pop(),
);
const NAME: Col<ItemRow> = { head: "이름", get: (r) => leafName(r) };
const FOLDER: Col<ItemRow> = { head: "폴더", get: (r) => folderOf(r).join(" / ") };
const UNIT: Col<ItemRow> = { head: "단위", get: (r) => r.단위 ?? "" };
const changed = (part: "structure" | "area" | "labor"): Col<ItemRow> => ({
head: "고친 날",
get: (r) => day(r.수정일?.[part]),
});
/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */
export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
structure: [
{ head: "도번", get: (r) => r.도번 || DASH },
NAME,
FOLDER,
UNIT,
changed("structure"),
],
area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")],
labor: [
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
NAME,
FOLDER,
UNIT,
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
{ head: "합계", get: (r) => money(r.labor?.합계) },
changed("labor"),
],
item: [
NAME,
FOLDER,
UNIT,
{ head: "도번", get: (r) => r.도번 || DASH },
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
{ head: "면적번", get: (r) => r.면적번 || DASH },
{ head: "고친 날", get: latest },
],
};
/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */
export function pageOf<R>(
rows: R[],
cols: Col<R>[],
query: string,
page: number,
size = SIZE,
): { shown: R[]; page: number; total: number } {
const hits = rows.filter((r) =>
matchText(
cols.map((c) => c.get(r)),
query,
),
);
const last = Math.max(1, Math.ceil(hits.length / size));
const at = Math.min(Math.max(1, page), last);
return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length };
}
/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */
export const loadItems = async (layer: Layer, owner: string | null): Promise<ItemRow[]> =>
(await fetchTree(layer, owner)).items as ItemRow[];
export interface ItemListHandle {
root: HTMLElement;
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
show: <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void) => void;
/** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */
message: (text?: string) => void;
}
export function buildItemList(): ItemListHandle {
let draw = (): void => {};
let page = 1;
const searchField = createInputField({ type: "search", placeholder: TEXT.search });
const search = searchField.input;
const grid = el("div", { className: "m01-master__grid-wrap" });
let pager = buildPager(0, SIZE, 1, () => {});
const note = (text: string): void => {
grid.replaceChildren(el("p", { className: "m01-master__empty", text }));
};
const show = <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void): void => {
page = 1;
draw = (): void => {
const got = pageOf(rows, cols, search.value, page);
page = got.page;
const next = buildPager(got.total, SIZE, page, (p) => {
page = p;
draw();
});
pager.replaceWith(next);
pager = next;
if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none);
const body = got.shown.map((row) => {
const tr = el("tr", {
className: "m01-master__table-row",
children: cols.map((c) => {
const text = c.get(row);
return el("td", { text, attrs: { title: text } });
}),
});
tr.addEventListener("click", () => onPick(row));
return tr;
});
grid.replaceChildren(
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })],
}),
el("tbody", { children: body }),
],
}),
);
};
draw();
};
search.addEventListener("input", () => {
page = 1;
draw();
});
const root = el("div", {
className: "m02-itemlist",
children: [buildHeadFilters([searchField.root, pager]), grid],
});
// 처음 = 빈 표 자리(줄이 오면 `show`)
note(TEXT.loading);
return { root, show, message: (text = TEXT.loading) => note(text) };
}
+39 -1
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -85,10 +86,47 @@ def items(folder: Path) -> list[dict[str, Any]]:
k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번")
},
}
out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names})
out.append(
{
"key": r.get("키"),
**{k: r.get(k) for k in ITEM_FIELDS},
"names": names,
**_list_extras(Path(folder), str(r.get("열"))),
}
)
return out
def _list_extras(folder: Path, col: str) -> dict[str, Any]:
"""목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의
번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음."""
stamps: dict[str, str] = {}
for kind in ("structure", "area", "labor"):
path = folder / kind / f"{col}.json"
if path.is_file():
stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat(
timespec="seconds"
)
labor = None
path = folder / "labor" / f"{col}.json"
if path.is_file():
try:
doc = json.loads(path.read_bytes())
except ValueError:
doc = {}
total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계")
try:
total = None if total is None or isinstance(total, bool) else float(total)
except (TypeError, ValueError):
total = None # 수 아닌 값(옛 꼴)은 「—」
labor = {
"번호": doc.get("일위대가번"),
"로직수": len(doc.get("담은로직") or []),
"합계": total,
}
return {"수정일": stamps, "labor": labor}
def _fresh_id(want: str, used: set[str]) -> str:
out, n = want, 2
while out in used:
+65
View File
@@ -0,0 +1,65 @@
/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고
* JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete");
const load = (name, stubs) => {
const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const need = (id) => (id in stubs ? stubs[id] : {});
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
};
const model = load("M02_MasterTemplete_TreePanel_Model.ts", {});
const m = load("M02_MasterTemplete_ItemList.ts", {
"./M02_MasterTemplete_TreePanel_Model": model,
});
const row = (열, 경로, extra = {}) => ({
key: `SS-${열}`,
열,
경로,
이름: 경로.join(" "),
단위: "m",
도번: `SD-${열}`,
면적번: `SA-${열}`,
수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" },
labor: null,
...extra,
});
const rows = [
row("c1", ["기슭막이", "메쌓기", "1.2m"], {
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 },
수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" },
}),
row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }),
row("c3", ["옹벽"], { 도번: "", 수정일: {} }),
];
const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r)));
const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`]));
process.stdout.write(
JSON.stringify({
heads: Object.fromEntries(
Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]),
),
cells: {
structure: cells("structure"),
area: cells("area"),
labor: cells("labor"),
item: cells("item"),
},
find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열),
findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열),
findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1),
page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))(
m.pageOf(many, m.ITEM_COLS.structure, "", 3),
),
pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page,
pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page,
}),
);
+94
View File
@@ -0,0 +1,94 @@
"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) ·
서버 목록 칸(임시 storage).
① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 ·
고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽.
② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 ·
합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
from resources.tester import test_m02_tree as base
from resources.tester.test_m02_tree import Tree, _as
world = base.world # 같은 임시 storage 판
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def ts() -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_item_list.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_종류마다_칸(ts):
assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"]
assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"]
assert ts["heads"]["labor"] == [
"일위대가번",
"이름",
"폴더",
"단위",
"담은 로직 수",
"합계",
"고친 날",
]
assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), [])
def test_칸_값_빈_번호_합계_날짜(ts):
s, labor = ts["cells"]["structure"], ts["cells"]["labor"]
assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"]
assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음)
assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림
assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음
assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날
def test_찾기_쪽(ts):
assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸)
assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음
assert ts["findNone"] == {"shown": [], "page": 1, "total": 0}
assert ts["page3"] == {"page": 3, "total": 65, "n": 5}
assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1
def test_서버_목록_칸_수정일_labor(world):
t = Tree(_as(world), "personal")
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m")
root = world["storage"] / "7" / "42" / "templates"
ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"])
assert set(ditch["수정일"]) >= {"structure", "area"}
assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO
assert ditch["labor"] is None # 일위대가 파일 없음
(root / "labor").mkdir(exist_ok=True)
doc = {
"일위대가번": "SU-000001",
"담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}],
"계산값": {"반영합": {"계": 12345.6}},
}
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
(root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8")
rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6}
assert "labor" in rows[ditch["열"]]["수정일"]
assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None}
doc["계산값"] = {"반영합": {"계": "글"}}
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
again = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
assert again[ditch["열"]]["labor"]["합계"] is None
+7 -1
View File
@@ -77,7 +77,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
rows = client.get("/api/m02/items").json()
ss = store.read_combos()["줄"]
assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001"
assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS}
assert set(rows[0]) == {
"key",
"names",
"수정일", # 목록 칸(39-5)
"labor",
*items_router.items_store.ITEM_FIELDS,
}
assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"}
assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos()
# 시스템 층 쓰기는 SYSTEM_ADMIN 만