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:
@@ -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) };
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
@@ -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 만
|
||||
|
||||
Reference in New Issue
Block a user