knowledge(마스터): 소요량·계수·로직에 구분·상세구분 — 표·줄마다 칸 · 갈래 목록은 서버가 줌

- 장 파일 137 개의 표 3,028 · 로직 1,351 줄에 구분(원문 + 부문 · 「건설품셈 공통」) · 상세구분(「03장 토공사」) 칸을 직접 둠 · 파일 머리의 부문·차례는 목록 차례로 남김
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · 그룹(맨 뒤) · 로직은 소유 다음에 비고
- 서버 — GET /subs?group= 이 구분·상세구분 목록(장 차례대로) · GET /tables 가 파일을 가로질러 구분·상세구분·찾기로 거르고 쪽 나눔 · GET /logics 도 구분·상세구분으로 거름 · 새 줄은 서버가 갈래를 채움 · 죽은 book 칸 걷어냄
- check_master 가 구분·상세구분이 파일 머리·이름과 맞는지 봄 · adopt 가 빌더 줄에 갈래를 찍어 되돌리기 시험 글자까지 같음 · _틀.md · 화면 계약 · 화면 트리 · 시험 같이 고침

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-20 16:30:48 +09:00
co-authored by Claude Opus 5
parent 9de61eb251
commit 10418ccbd4
154 changed files with 8227 additions and 8046 deletions
+21 -5
View File
@@ -23,7 +23,7 @@ export interface FileInfo {
version: string;
}
/** 하위 거름 한 갈래 — 인력 「구분」 · 기계 「세부분류」(머리에 등록된 것). */
/** 하위 거름 한 갈래 — 인력 「구분」 · 기계 「세부분류」 · 장 그룹은 원문+부문(서버가 모음). */
export interface SubInfo {
name: string;
book: string | null;
@@ -42,8 +42,11 @@ export interface RowsPage {
}
export interface TableHead {
file: string;
키: string;
원문번호: string;
구분: string;
상세구분: string;
이름: string;
기준: string;
출처: string;
@@ -52,6 +55,13 @@ export interface TableHead {
count: number;
}
export interface TablesPage {
total: number;
page: number;
size: number;
tables: TableHead[];
}
export interface Change {
op: "edit" | "add" | "delete";
key?: string;
@@ -104,13 +114,19 @@ export const fetchRows = (
): Promise<RowsPage> =>
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail });
export const fetchSubs = async (file: string): Promise<SubInfo[]> =>
(await get<{ subs: SubInfo[] }>("/subs", { file })).subs;
/** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */
export const fetchSubs = async (where: { file?: string; group?: string }): Promise<SubInfo[]> =>
(await get<{ subs: SubInfo[] }>("/subs", { file: where.file ?? "", group: where.group ?? "" }))
.subs;
export const fetchTables = (
file: string,
group: string,
sub: string,
detail: string,
q: string,
): Promise<{ version: string; tables: TableHead[] }> => get("/tables", { file, q });
page: number,
size: number,
): Promise<TablesPage> => get("/tables", { group, sub, detail, q, page, size });
export const fetchTable = (
file: string,
+17 -10
View File
@@ -17,7 +17,6 @@ router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
class CalcBody(BaseModel):
book: str = "" # 화면 호환 — 키는 전체에서 하나
key: str
inputs: dict[str, Any] = {}
row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로)
@@ -69,8 +68,8 @@ def get_files(group: str) -> dict:
@router.get("/subs")
def get_subs(file: str) -> dict:
return _call(store.subs, file)
def get_subs(file: str = "", group: str = "") -> dict:
return _call(store.subs, file, group)
@router.get("/rows")
@@ -87,8 +86,16 @@ def get_rows(
@router.get("/tables")
def get_tables(file: str, q: str = "") -> dict:
return _call(store.tables, file, q)
def get_tables(
file: str = "",
group: str = "",
sub: str = "",
detail: str = "",
q: str = "",
page: int = 1,
size: int = 30,
) -> dict:
return _call(store.tables, file, group, sub, detail, q, page, size)
@router.get("/table")
@@ -97,13 +104,13 @@ def get_table(file: str, key: str) -> dict:
@router.get("/logics")
def get_logics(book: str = "", chapter: str = "", q: str = "", blocked: int | None = None) -> dict:
return {"logics": _call(store.logics, book, chapter, q, blocked)}
def get_logics(sub: str = "", detail: str = "", q: str = "", blocked: int | None = None) -> dict:
return {"logics": _call(store.logics, sub, detail, q, blocked)}
@router.get("/logic")
def get_logic(key: str, book: str = "") -> dict:
return _call(store.logic, book, key)
def get_logic(key: str) -> dict:
return _call(store.logic, key)
@router.get("/elements")
@@ -118,7 +125,7 @@ def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.book, body.key, body.inputs, body.row, body.file)
return _call(store.calc, body.key, body.inputs, body.row, body.file)
@router.post("/save")
+86 -24
View File
@@ -127,8 +127,22 @@ def refs_of(rows_: list[dict]) -> dict[str, str]:
return out
def subs(file: str) -> dict:
"""하위 거름 목록 — 인력 「구분」 · 기계 「세부분류」 묶음(머리에 등록된 것) · 그 밖은 빈 목록."""
def subs(file: str = "", group: str = "") -> dict:
"""하위 거름 목록 — 한 테이블 파일은 머리 묶음(인력 「구분」 · 기계 「세부분류」) ·
장 그룹(소요량 · 계수 · 로직)은 장 파일에서 모음(구분 = 원문 + 부문 · 상세구분 = 장 · 차례대로)."""
if group:
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}」")
found: dict[str, dict] = {}
for info in files_of(group):
name = mk.CHAPTER.match(info["file"])
if not name:
continue
data = read(info["file"])[0]
kind = " ".join(x for x in (data.get("원문"), data.get("부문")) if x)
slot = found.setdefault(kind, {"name": kind, "book": data.get("원문"), "details": []})
slot["details"].append(mk.detail_of(info["file"]))
return {"file": "", "version": "", "slot": "구분", "subs": list(found.values())}
data, version = read(file)
slot = next((s for s in ("구분", "세부분류") if isinstance(data.get(s), dict)), "")
return {
@@ -172,19 +186,51 @@ def rows(
}
def tables(file: str, q: str) -> dict:
data, version = read(file)
if items_key(data) != "표":
raise StoreError(400, f"표형 파일 아님 「{file}」")
heads = ("키", "원문번호", "이름", "기준", "출처", "조건", "값칸")
_TABLE_HEADS = ("키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "조건", "값칸")
def tables(
file: str = "",
group: str = "",
sub: str = "",
detail: str = "",
q: str = "",
page: int = 1,
size: int = 30,
) -> dict:
"""표 목록 — 파일 하나 또는 그룹 전체(구분 · 상세구분 · 찾기로 거름 · 장 차례대로 · 쪽 나눔)."""
if file:
names = [file]
elif group in cm.mf.TABLE_GROUPS:
names = [f["file"] for f in files_of(group)]
else:
raise StoreError(404, f"없는 표형 그룹 「{group}」")
hits, version = [], ""
for name in names:
data, version = read(name)
if items_key(data) != "표":
raise StoreError(400, f"표형 파일 아님 「{name}」")
for t in data["표"]:
if sub and t.get("구분") != sub:
continue
if detail and t.get("상세구분") != detail:
continue
if _hit(t, q):
hits.append(
{
"file": name,
**{k: t.get(k) for k in _TABLE_HEADS},
"count": len(t.get("줄") or []),
}
)
page, size = max(page, 1), min(max(size, 1), 500)
return {
"file": file,
"version": version,
"tables": [
{**{k: t.get(k) for k in heads}, "count": len(t.get("줄") or [])}
for t in data["표"]
if _hit(t, q)
],
"version": version if file else "",
"total": len(hits),
"page": page,
"size": size,
"tables": hits[(page - 1) * size : page * size],
}
@@ -206,14 +252,15 @@ def _logic_files(files: dict[str, dict]):
yield name, data
def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]:
"""로직 목록 — 구분 · 상세구분 · 찾기로 거름(장 차례대로 · 파일을 가로지름)."""
files = cm.load(folder=FOLDER)
whole = cm.mf.Master(files)
out = []
for name, data in sorted(_logic_files(files), key=lambda f: f[1].get("차례") or 0):
if (book and data.get("원문") != book) or (chapter and chapter_of(name, data) != chapter):
continue
for row in data.get("줄") or []:
if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail):
continue
if not _hit(row, q):
continue
reasons = cm.mf.check_logic(whole, row)[0]
@@ -222,9 +269,10 @@ def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
out.append(
{
"file": name,
"book": data.get("원문"),
"chapter": chapter_of(name, data),
**{k: row.get(k) for k in ("키", "원문번호", "이름", "결과단위", "출처")},
**{
k: row.get(k)
for k in ("키", "원문번호", "구분", "상세구분", "이름", "결과단위", "출처")
},
"blocked": bool(reasons),
"reasons": reasons,
}
@@ -232,8 +280,8 @@ def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
return out
def logic(book: str, key: str) -> dict:
"""로직 하나 — 키는 전체에서 하나(`book` 은 화면 호환으로만 받음)."""
def logic(key: str) -> dict:
"""로직 하나 — 키는 전체에서 하나."""
files = cm.load(folder=FOLDER)
for name, data in _logic_files(files):
for row in data.get("줄") or []:
@@ -349,7 +397,7 @@ def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
return new
def calc(book: str, key: str, inputs: dict, row: dict | None = None, file: str | None = None):
def calc(key: str, inputs: dict, row: dict | None = None, file: str | None = None):
given = {k: _dec(v) for k, v in inputs.items()}
files = cm.load(folder=FOLDER)
if row is not None:
@@ -382,6 +430,16 @@ def _dec(v):
return v
def _chapter_cols(file: str, data: dict) -> dict:
"""장 파일에 더하는 줄의 갈래 — 구분 = 원문 + 부문 · 상세구분 = 「NN장 장 제목」."""
if not mk.CHAPTER.match(file):
return {}
return {
"구분": " ".join(x for x in (data.get("원문"), data.get("부문")) if x),
"상세구분": mk.detail_of(file),
}
def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
items = data.setdefault(items_key(data), [])
@@ -398,8 +456,12 @@ def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
raise StoreError(400, f"{file} · row 없음")
if op == "add":
number = str(row.get("원문번호") or "")
rest = {k: v for k, v in row.items() if k not in ("키", "원문번호")}
items.append({"키": mk.issue(book, tid, number, file), "원문번호": number, **rest})
cols = _chapter_cols(file, data)
drop = ("키", "원문번호", *cols)
rest = {k: v for k, v in row.items() if k not in drop}
items.append(
{"키": mk.issue(book, tid, number, file), "원문번호": number, **cols, **rest}
)
elif op == "edit":
if str(row.get("키")) != key:
raise StoreError(400, f"{file} · 키는 바꾸지 않음 「{key}」")
+13 -5
View File
@@ -47,8 +47,8 @@ export interface LogicRow {
export interface LogicSummary {
file: string;
book: string;
chapter: string;
구분: string;
상세구분: string;
키: string;
원문번호: string;
이름: string;
@@ -97,6 +97,12 @@ export type CalcAnswer =
}
| { ok: false; reason: string };
export interface SubBrief {
name: string;
book: string | null;
details: string[];
}
export interface LogicFile {
file: string;
book: string;
@@ -142,8 +148,11 @@ const query = (params: Record<string, string>): string => new URLSearchParams(pa
export const fetchLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics);
export const fetchLogic = (book: string, key: string): Promise<LogicOne> =>
request(`/logic?${query({ book, key })}`);
/** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */
export const fetchLogicSubs = (): Promise<SubBrief[]> =>
request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs);
export const fetchLogic = (key: string): Promise<LogicOne> => request(`/logic?${query({ key })}`);
export const fetchLogicFiles = (): Promise<LogicFile[]> =>
request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then(
@@ -157,7 +166,6 @@ export const searchElements = (
request(`/elements?${query({ group, q, limit: "100" })}`);
export const runCalc = (body: {
book: string;
key: string;
inputs: Record<string, unknown>;
row?: LogicRow;
@@ -15,7 +15,6 @@ import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export interface CalcContext {
book: string;
/** 저장된 키 — 새 로직은 null */
savedKey: string | null;
file: string;
@@ -67,7 +66,6 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const draft = ctx.dirty() || ctx.savedKey === null;
try {
const answer = await runCalc({
book: ctx.book,
key: ctx.savedKey ?? ctx.row.키,
inputs,
...(draft ? { row: ctx.row, file: ctx.file } : {}),
+32 -50
View File
@@ -1,20 +1,20 @@
/* =============================================================================
* M01_MasterData_UI_Logic_List.ts
* 왼쪽 로직 목록 — 원문 · 장 · 이름 찾기 · 막힘만 · 막힘(⛔)·고침(●) 표시
* 왼쪽 로직 목록 — 구분 · 상세구분 · 이름 찾기 · 막힘만 · 막힘(⛔)·고침(●) 표시
* 거르기는 화면에서(목록은 한 번 받음 · 391 줄 남짓)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import type { LogicSummary, SubBrief } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type ListMark = "edited" | "new" | "deleted";
export interface ListItem {
id: string;
book: string;
chapter: string;
sub: string;
detail: string;
key: string;
number: string;
name: string;
@@ -24,21 +24,22 @@ export interface ListItem {
export interface ListHandle {
root: HTMLElement;
setItems: (items: LogicSummary[]) => void;
setItems: (items: LogicSummary[], subs: SubBrief[]) => void;
/** 저장 안 한 새 로직도 목록 맨 위에 */
setMarks: (marks: Map<string, ListMark>, extra: ListItem[]) => void;
setActive: (id: string | null) => void;
}
export const logicId = (book: string, key: string): string => `${book}\n${key}`;
export const logicId = (sub: string, key: string): string => `${sub}\n${key}`;
export function buildList(onOpen: (item: ListItem) => void): ListHandle {
let items: ListItem[] = [];
let subs: SubBrief[] = [];
let extra: ListItem[] = [];
let marks = new Map<string, ListMark>();
let active: string | null = null;
/** 트리에서 고른 범위 — 원문 · 부문 · 장(비면 전체) */
const pick = { book: "", division: "", chapter: "" };
/** 트리에서 고른 범위 — 구분 · 상세구분(비면 전체) */
const pick = { sub: "", detail: "" };
const tree = el("div", { className: "m01-logic__tree" });
const search = el("input", {
className: "m01-logic__input",
@@ -48,11 +49,8 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
const count = el("span", { className: "m01-logic__muted" });
const list = el("ul", { className: "m01-logic__list" });
const divisionOf = (chapter: string): string => /^(\S+) \d+장/.exec(chapter)?.[1] ?? "";
const inPick = (x: ListItem): boolean =>
(!pick.book || x.book === pick.book) &&
(!pick.division || divisionOf(x.chapter) === pick.division) &&
(!pick.chapter || x.chapter === pick.chapter);
(!pick.sub || x.sub === pick.sub) && (!pick.detail || x.detail === pick.detail);
const make: MakeRow = (node, caret, depth, onClick) => {
const button = el("button", {
@@ -77,13 +75,12 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
return button;
};
let pickId = "";
/** 원문 › 부문 › 장 — 눌러 범위를 고름 */
/** 전체 › 구분 › 상세구분 — 눌러 범위를 고름(갈래 목록은 서버가 준 것) */
const drawTree = (): void => {
const count = (f: (x: ListItem) => boolean): number => items.filter(f).length;
const books = [...new Set(items.map((x) => x.book))];
const set = (id: string, book: string, division: string, chapter: string): void => {
const set = (id: string, sub: string, detail: string): void => {
pickId = id;
Object.assign(pick, { book, division, chapter });
Object.assign(pick, { sub, detail });
draw();
};
const nodes: TreeNode[] = [
@@ -92,35 +89,19 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
label: tx("List_AllBooks"),
count: items.length,
open: true,
run: () => set("all", "", "", ""),
children: books.map((b): TreeNode => {
const chapters = [...new Set(items.filter((x) => x.book === b).map((x) => x.chapter))];
const leaf = (c: string, label: string): TreeNode => ({
id: `${b}|${c}`,
label,
count: count((x) => x.book === b && x.chapter === c),
run: () => set(`${b}|${c}`, b, "", c),
});
const divisions = [...new Set(chapters.map(divisionOf).filter(Boolean))];
return {
id: b,
label: b,
count: count((x) => x.book === b),
run: () => set(b, b, "", ""),
children: [
...chapters.filter((c) => !divisionOf(c)).map((c) => leaf(c, c)),
...divisions.map((d): TreeNode => ({
id: `${b}|#${d}`,
label: d,
count: count((x) => x.book === b && divisionOf(x.chapter) === d),
run: () => set(`${b}|#${d}`, b, d, ""),
children: chapters
.filter((c) => divisionOf(c) === d)
.map((c) => leaf(c, c.slice(d.length + 1))),
})),
],
};
}),
run: () => set("all", "", ""),
children: subs.map((s): TreeNode => ({
id: s.name,
label: s.name,
count: count((x) => x.sub === s.name),
run: () => set(s.name, s.name, ""),
children: s.details.map((d) => ({
id: `${s.name}|${d}`,
label: d,
count: count((x) => x.sub === s.name && x.detail === d),
run: () => set(`${s.name}|${d}`, s.name, d),
})),
})),
},
];
tree.replaceChildren(...renderTree(nodes, make));
@@ -159,7 +140,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
children: [
el("span", {
className: "m01-logic__muted",
text: `${item.book} ${item.chapter} · ${item.number}`,
text: `${item.sub} ${item.detail} · ${item.number}`,
}),
el("span", { text: item.name || item.number || item.key }),
el("span", { className: "m01-logic__badges", children: badges }),
@@ -192,11 +173,12 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
root.prepend(tree);
return {
root,
setItems: (logics) => {
setItems: (logics, kinds) => {
subs = kinds;
items = logics.map((x) => ({
id: logicId(x.book, x.키),
book: x.book,
chapter: x.chapter,
id: logicId(x.구분, x.키),
sub: x.구분,
detail: x.상세구분,
key: x.키,
number: x.원문번호,
name: x.이름,
+24 -15
View File
@@ -20,6 +20,7 @@ import {
fetchLogic,
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
saveFiles,
type CalcLine,
type ElementBrief,
@@ -36,7 +37,8 @@ import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
interface Draft {
book: string;
/** 구분(원문 + 부문) — 목록 id 를 세움 */
sub: string;
file: string;
version: string;
origKey: string | null;
@@ -74,7 +76,7 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("aside", { className: "m01-logic__calc" });
const pending = el("span", { className: "m01-logic__muted" });
const list = buildList((item) => void open(item.id, item.book, item.key));
const list = buildList((item) => void open(item.id, item.sub, item.key));
const persist = (): void => {
try {
@@ -89,8 +91,8 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
if (d.origKey === null && d.row) {
extra.push({
id,
book: d.book,
chapter: "",
sub: d.sub,
detail: "",
key: d.row.키,
number: d.row.원문번호,
name: d.row.이름,
@@ -116,7 +118,7 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
book: o.book,
sub: o.sub,
file: o.file,
version: o.version,
origKey: o.origKey,
@@ -131,7 +133,6 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
const current = opened;
calcInputs = JSON.stringify(current.row?.입력 ?? []);
buildCalc(calc, {
book: current.book,
savedKey: current.origKey,
file: current.file,
row: current.row as LogicRow,
@@ -169,13 +170,17 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
onFile: (file) => {
const f = files.find((x) => x.file === file);
if (!f) return;
Object.assign(current, { file: f.file, book: f.book, version: f.version });
Object.assign(current, { file: f.file, sub: subOf(f), version: f.version });
touch();
},
onPick: openPicker,
});
};
/** 로직 파일 → 구분(원문 + 부문) — 「공통 03장 토공사」 의 앞말이 부문 */
const subOf = (f: LogicFile): string =>
f.book === "건설품셈" ? `${f.book} ${f.chapter.split(" ")[0]}` : String(f.book);
const show = (next: Opened | null): void => {
opened = next;
errors.hidden = true;
@@ -183,17 +188,17 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
drawCalc();
};
const open = async (id: string, book: string, key: string): Promise<void> => {
const open = async (id: string, sub: string, key: string): Promise<void> => {
const draft = drafts[id];
if (draft?.origKey === null) {
show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} });
return;
}
try {
const one = await fetchLogic(book, key);
const one = await fetchLogic(key);
show({
id,
book,
sub,
file: draft?.file ?? one.file,
version: draft?.version ?? one.version,
origKey: key,
@@ -210,9 +215,13 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
};
const reload = async (): Promise<void> => {
const [logics, logicFiles] = await Promise.all([fetchLogics(), fetchLogicFiles()]);
const [logics, logicFiles, subs] = await Promise.all([
fetchLogics(),
fetchLogicFiles(),
fetchLogicSubs(),
]);
files = logicFiles;
list.setItems(logics);
list.setItems(logics, subs);
persist();
};
@@ -237,7 +246,7 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
};
const next: Opened = {
id,
book: f.book,
sub: subOf(f),
file: f.file,
version: f.version,
origKey: null,
@@ -280,7 +289,7 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
persist();
const was = opened;
if (was && was.origKey !== null)
await open(logicId(was.book, was.origKey), was.book, was.origKey);
await open(logicId(was.sub, was.origKey), was.sub, was.origKey);
else show(null);
};
@@ -305,7 +314,7 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
if (was?.row?.키) await open(logicId(was.book, was.row.키), was.book, was.row.키);
if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
else show(null);
} catch (error) {
failed(error);
+1 -1
View File
@@ -85,7 +85,7 @@ function buildPage(): HTMLElement {
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
title.textContent = groupTitle.join(" · ");
const view = TABLE_GROUPS.includes(pick.group) ? renderTables : renderRows;
dispose = view(body, pick.file.file, query, pick.sub, pick.detail);
dispose = view(body, pick, query);
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
};
+4 -7
View File
@@ -6,6 +6,7 @@
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openPickModal } from "./M01_MasterData_UI_Pick";
import type { Pick } from "./M01_MasterData_UI_Side";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import {
addRow,
@@ -88,13 +89,9 @@ const remember = (ref: string | null, name?: string): void => {
};
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRows(
host: HTMLElement,
file: string,
q: string,
sub = "",
detail = "",
): () => void {
export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void {
const { sub, detail } = pick;
const file = pick.file.file;
let page = 1;
let data: RowsPage | null = null;
let unlinked = false;
+34 -17
View File
@@ -15,7 +15,7 @@ import {
type FileInfo,
type SubInfo,
} from "./M01_MasterData_Api_Fetch";
import { bookTree, renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree";
/** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */
export interface Pick {
@@ -212,21 +212,36 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
return;
}
if (group === "소요량" || group === "계수") {
const tree = bookTree(
files,
(f, division): TreeNode => {
const label = fileLabel(f.file);
return {
id: `${group}\n${f.file}`,
label: division ? f.chapter.slice(division.length + 1) : f.chapter,
count: f.rows,
run: () => open(f, "", label),
};
},
(parts) => `${group}\n#${parts.join("\n")}`,
(list) => list.reduce((n, f) => n + f.rows, 0),
);
body.replaceChildren(...renderTree(tree, make));
// 전체 → 구분(원문+부문) → 상세구분(장) — 목록은 서버가 준 것 · 장 차례대로
const list = kinds.get(group) ?? [];
const chapter = (k: SubInfo, detail: string): FileInfo | undefined =>
files.find((f) => f.file === `${group}_${k.book}_${detail.replaceAll(" ", "_")}.json`);
const rows = (k: SubInfo): number =>
k.details.reduce((n, d) => n + (chapter(k, d)?.rows ?? 0), 0);
const all: TreeNode[] = one
? [
{
id: `${group}\n`,
label: L("M01_All"),
count: files.reduce((n, f) => n + f.rows, 0),
open: true,
run: () => open(one, "", L("M01_All")),
children: list.map((k) => ({
id: `${group}\n${k.name}`,
label: k.name,
count: rows(k),
run: () => open(chapter(k, k.details[0] ?? "") ?? one, k.name, k.name),
children: k.details.map((d) => ({
id: `${group}\n${k.name}\n${d}`,
label: d,
count: chapter(k, d)?.rows ?? null,
run: () => open(chapter(k, d) ?? one, k.name, `${k.name} · ${d}`, d),
})),
})),
},
]
: [];
body.replaceChildren(...renderTree(all, make));
return;
}
body.replaceChildren(
@@ -245,13 +260,15 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
const list = await fetchFiles(group);
if (list[0] && (group === "인력" || group === "기계")) {
const file = list[0].file;
const subs = await fetchSubs(file);
const subs = await fetchSubs({ file });
kinds.set(group, subs);
await Promise.all(
(group === "기계" ? subs : []).map(async (k) =>
counts.set(`${group}\n${k.name}`, (await fetchRows(file, 1, 1, "", false, k.name)).total),
),
);
} else if (group === "소요량" || group === "계수") {
kinds.set(group, await fetchSubs({ group }));
}
lists.set(group as Group, list);
drawGroup(group as Group);
+18 -24
View File
@@ -1,11 +1,13 @@
/* =============================================================================
* M01_MasterData_UI_Tables.ts
* 표형 파일(소요량·계수) — 표 목록 → 표 하나(조건 칸 + 값 칸의 긴 줄) · 눌러 고침(노랑) · 줄 더하기·지우기
* 표형 그룹(소요량·계수) — 구분·상세구분·찾기로 거른 표 목록(파일을 가로지름 · 쪽 나눔)
* → 표 하나(조건 칸 + 값 칸의 긴 줄) · 눌러 고침(노랑) · 줄 더하기·지우기
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchTable, fetchTables, type Row, type TableHead } from "./M01_MasterData_Api_Fetch";
import type { Pick } from "./M01_MasterData_UI_Side";
import {
onDraftChange,
peek,
@@ -26,25 +28,17 @@ import {
const LIST_SIZE = 30;
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderTables(
host: HTMLElement,
file: string,
q: string,
_sub = "",
_detail = "",
): () => void {
export function renderTables(host: HTMLElement, pick: Pick, q: string): () => void {
let stop = (): void => {};
const list = (page = 1): void => {
stop();
stop = (): void => {};
void showList(host, file, q, page, (key) => {
void showList(host, pick, q, page, (file, key) => {
stop = openTable(host, file, key, () => list(page));
});
};
list();
const off = onDraftChange(
() => host.querySelector(".m01-master__tables") && markChanged(host, file),
);
const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host));
return () => {
stop();
off();
@@ -52,36 +46,36 @@ export function renderTables(
}
/** 목록 위 「고침」 표시만 다시 — 목록을 다시 받지 않음. */
function markChanged(host: HTMLElement, file: string): void {
const changed = peek(file)?.tables ?? {};
function markChanged(host: HTMLElement): void {
host.querySelectorAll<HTMLElement>(".m01-master__table-card").forEach((card) => {
const changed = peek(card.dataset.file!)?.tables ?? {};
card.classList.toggle("is-changed", card.dataset.key! in changed);
});
}
async function showList(
host: HTMLElement,
file: string,
pick: Pick,
q: string,
page: number,
open: (key: string) => void,
open: (file: string, key: string) => void,
): Promise<void> {
let tables: TableHead[];
let got: { total: number; tables: TableHead[] };
try {
tables = (await fetchTables(file, q)).tables;
got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE);
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const cards = tables.slice((page - 1) * LIST_SIZE, page * LIST_SIZE).map((t) => {
const cards = got.tables.map((t) => {
const card = el("button", {
className: "m01-master__table-card",
attrs: { type: "button", "data-key": t.키 },
attrs: { type: "button", "data-key": t.키, "data-file": t.file },
children: [
el("strong", { text: t.이름 || t.원문번호 }),
el("span", {
className: "m01-master__muted",
text: `${t.키} ${t.원문번호} · ${t.기준 || "—"}`,
text: `${t.상세구분} · ${t.키} ${t.원문번호} · ${t.기준 || "—"}`,
}),
el("span", {
className: "m01-master__muted",
@@ -89,12 +83,12 @@ async function showList(
}),
],
});
card.classList.toggle("is-changed", t.키 in (peek(file)?.tables ?? {}));
card.addEventListener("click", () => open(t.키));
card.classList.toggle("is-changed", t.키 in (peek(t.file)?.tables ?? {}));
card.addEventListener("click", () => open(t.file, t.키));
return card;
});
host.replaceChildren(
buildPager(tables.length, LIST_SIZE, page, (p) => void showList(host, file, q, p, open)),
buildPager(got.total, LIST_SIZE, page, (p) => void showList(host, pick, q, p, open)),
el("div", { className: "m01-master__tables", children: cards }),
);
}
-33
View File
@@ -42,36 +42,3 @@ export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLEle
return el("div", { className: "m01-tree__branch", children: [row, kids] });
});
}
/** 파일 목록 → 원문 › 부문 › 장 — 장 이름이 「공통 03장 …」 이면 부문 「공통」 */
export function bookTree<T extends { book: string | null; chapter: string }>(
items: T[],
leaf: (item: T, division: string) => TreeNode,
id: (parts: string[]) => string,
count: (list: T[]) => number,
): TreeNode[] {
const books = new Map<string, Map<string, T[]>>();
for (const it of items) {
const division = /^(\S+) \d+장/.exec(it.chapter)?.[1] ?? "";
const divs = books.get(it.book ?? "") ?? new Map<string, T[]>();
divs.set(division, [...(divs.get(division) ?? []), it]);
books.set(it.book ?? "", divs);
}
return [...books].map(([book, divs]) => ({
id: id([book]),
label: book,
count: count([...divs.values()].flat()),
children: [...divs].flatMap(([division, list]) =>
division
? [
{
id: id([book, division]),
label: division,
count: count(list),
children: list.map((it) => leaf(it, division)),
},
]
: list.map((it) => leaf(it, "")),
),
}));
}