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:
@@ -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,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")
|
||||
|
||||
@@ -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}」")
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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.이름,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")] : []);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, "")),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user