diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index 11710dd9..8cbb70b0 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -30,6 +30,8 @@ export interface RowsPage { page: number; size: number; rows: Row[]; + /** 참조 칸 키 → 이름(키 옆에 같이 보임) */ + refs: Record; } export interface TableHead { @@ -90,7 +92,9 @@ export const fetchRows = ( size: number, q: string, unlinked = false, -): Promise => get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0 }); + sub = "", +): Promise => + get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub }); export const fetchTables = ( file: string, diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index f9ff0e84..2e94c4b5 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -69,8 +69,10 @@ def get_files(group: str) -> dict: @router.get("/rows") -def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False) -> dict: - return _call(store.rows, file, page, size, q, unlinked) +def get_rows( + file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False, sub: str = "" +) -> dict: + return _call(store.rows, file, page, size, q, unlinked, sub) @router.get("/tables") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index ee5e1a5b..1a98bcf8 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -98,19 +98,57 @@ def files_of(group: str) -> list[dict]: return sorted(out, key=lambda f: (f["order"], f["file"])) -def rows(file: str, page: int, size: int, q: str, unlinked: bool = False) -> dict: +_REF_KEY = re.compile(r"^[A-Z]{2}\d{6}$") +_REF_FILE = { + "LB": "인력.json", + "MT": "재료_시중물가.json", + "MN": "재료_나라장터자재.json", + "MP": "재료_품셈재료.json", + "EQ": "기계.json", +} + + +def _ref_keys(row: dict) -> list[str]: + """줄이 가리키는 키 — 준용 · 연결 · 조달 출처(「나라장터:<키>」).""" + put = ( + row.get("준용"), + row.get("연결"), + (row.get("값") or {}).get("조달", {}).get("출처") + if isinstance(row.get("값"), dict) + else None, + ) + out = [v.removeprefix("나라장터:") for v in put if isinstance(v, str)] + return [k for k in out if _REF_KEY.match(k)] + + +def refs_of(rows_: list[dict]) -> dict[str, str]: + """참조 칸에 키 옆에 보일 이름 — {키: 「이름 규격」}.""" + want = {k for r in rows_ for k in _ref_keys(r)} + out: dict[str, str] = {} + for file in {_REF_FILE[k[:2]] for k in want if k[:2] in _REF_FILE}: + for r in read(file)[0].get("줄") or []: + if r.get("키") in want: + out[r["키"]] = f"{r.get('이름') or ''} {r.get('규격') or ''}".strip() + return out + + +def rows(file: str, page: int, size: int, q: str, unlinked: bool = False, sub: str = "") -> dict: data, version = read(file) hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)] + if sub: # 인력 조사 · 기계 세부분류 + hits = [r for r in hits if sub in (r.get("조사"), r.get("세부분류"))] if unlinked: # 품셈재료 — 아직 못 이은 줄 hits = [r for r in hits if not r.get("연결")] page, size = max(page, 1), min(max(size, 1), 500) + shown = hits[(page - 1) * size : page * size] return { "file": file, "version": version, "total": len(hits), "page": page, "size": size, - "rows": hits[(page - 1) * size : page * size], + "rows": shown, + "refs": refs_of(shown), } @@ -152,7 +190,7 @@ def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]: files = cm.load(folder=FOLDER) whole = cm.mf.Master(files) out = [] - for name, data in _logic_files(files): + 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 []: @@ -393,14 +431,16 @@ def _atom(v) -> str: return json.dumps(v, ensure_ascii=False) -def _flat(v, tight: bool = False) -> str: +def _flat(v, tight: bool = False, inner: bool | None = None) -> str: + """한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음).""" + inner = tight if inner is None else inner if isinstance(v, dict): if not v: return "{}" - body = ", ".join(f"{_atom(k)}: {_flat(x, tight)}" for k, x in v.items()) + body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items()) return "{" + body + "}" if tight else "{ " + body + " }" if isinstance(v, list): - return "[" + ", ".join(_flat(x, tight) for x in v) + "]" + return "[" + ", ".join(_flat(x, inner) for x in v) + "]" return _atom(v) @@ -443,7 +483,8 @@ def keep_shape(old_text: str, before: dict, after: dict) -> str | None: if was == row: parts.append(text) elif "\n" not in shape: - parts.append(_flat(row, tight=shape.startswith('{"'))) + tight = shape.startswith('{"') + parts.append(_flat(row, tight, tight or ': {"' in shape)) else: parts.append(dump(row, indent, expand=True).replace("\n", nl)) out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :] diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index 1c1c1ab1..0f681d0f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -15,6 +15,7 @@ export interface ListItem { book: string; chapter: string; key: string; + number: string; name: string; blocked: boolean; reasons: string[]; @@ -65,7 +66,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { (!book.value || x.book === book.value) && (!chapter.value || x.chapter === chapter.value) && (!blockedOnly.checked || x.blocked) && - (!q || x.key.toLowerCase().includes(q) || x.name.toLowerCase().includes(q)), + (!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))), ); count.textContent = tx("List_Count", { n: shown.length }); list.replaceChildren(...shown.slice(0, 500).map(line)); @@ -90,8 +91,11 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { className: `m01-logic__item${item.id === active ? " is-active" : ""}`, attrs: { type: "button", title: item.key }, children: [ - el("span", { className: "m01-logic__muted", text: `${item.book} ${item.chapter}` }), - el("span", { text: item.name || item.key }), + el("span", { + className: "m01-logic__muted", + text: `${item.book} ${item.chapter} · ${item.number}`, + }), + el("span", { text: item.name || item.number || item.key }), el("span", { className: "m01-logic__badges", children: badges }), ], }); @@ -134,6 +138,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { book: x.book, chapter: x.chapter, key: x.키, + number: x.원문번호, name: x.이름, blocked: x.blocked, reasons: x.reasons, diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index e659906b..b031e581 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Logic_Page.ts - * 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 - * 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫) + * 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 + * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. @@ -64,7 +64,7 @@ function loadDrafts(): Record { } } -export async function mountM01Logic(host: HTMLElement): Promise { +export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): Promise { let drafts = loadDrafts(); let files: LogicFile[] = []; let opened: Opened | null = null; @@ -91,7 +91,8 @@ export async function mountM01Logic(host: HTMLElement): Promise { id, book: d.book, chapter: "", - key: d.row.키 || d.row.원문번호, + key: d.row.키, + number: d.row.원문번호, name: d.row.이름, blocked: false, reasons: [], @@ -350,12 +351,12 @@ export async function mountM01Logic(host: HTMLElement): Promise { el("div", { className: "m01-logic", children: [ - list.root, el("main", { className: "m01-logic__main", children: [bar, errors, editor] }), calc, ], }), ); + listHost.replaceChildren(list.root); show(null); showLoadingOverlay(); try { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css index 0bbf5961..b4477c1d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -5,7 +5,7 @@ .m01-logic { display: grid; - grid-template-columns: 240px minmax(0, 1fr) 280px; + grid-template-columns: minmax(0, 1fr) 280px; gap: var(--spacing-12); height: 100%; min-height: 0; @@ -25,7 +25,6 @@ overflow: auto; } -.m01-logic__side, .m01-logic__calc { padding: var(--spacing-8); border-radius: var(--radius-cards); @@ -386,3 +385,8 @@ flex-wrap: wrap; gap: var(--spacing-4); } + +/* 왼쪽 패널 컨테이너 안 로직 목록 */ +.m01-side__items .m01-logic__list { + max-height: 40vh; +} diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 2e7af22e..ebe9c970 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -1,38 +1,27 @@ /* ============================================================================= * M01_MasterData_UI_Page.ts - * 마스터 요소 화면 — 좌측 도킹 패널(그룹 탭 7 · 파일 고르기) / 우측 요소 표 · 표 목록 + * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직) + * / 우측 요소 표 · 표 목록 · 로직 편집 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — - * 자동저장 없음. 로직 그룹은 다음 화면. + * 자동저장 없음. * ========================================================================== */ import "@ui/ui_template_workflow_layout.css"; -import { ROUTES } from "@config/config_frontend"; -import { - createButton, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { - fetchFiles, - fetchGroups, - saveFiles, - type FileInfo, - type GroupInfo, -} from "./M01_MasterData_Api_Fetch"; +import { saveFiles } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; import { renderRows } from "./M01_MasterData_UI_Rows"; +import { buildSide, fileLabel, type Pick } from "./M01_MasterData_UI_Side"; import { renderTables } from "./M01_MasterData_UI_Tables"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; -const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, ""); export async function renderM01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); @@ -41,24 +30,15 @@ export async function renderM01MasterData(root: HTMLElement): Promise { navigateTo(ROUTES.B01_ACCOUNT); return; } - showLoadingOverlay(); - let groups: GroupInfo[] = []; - try { - groups = (await fetchGroups()).filter((g) => g.group !== "로직"); - } catch (error) { - showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); - } finally { - hideLoadingOverlay(); - } root.innerHTML = ""; - root.append(buildPage(groups)); + root.append(buildPage()); } -function buildPage(groups: GroupInfo[]): HTMLElement { - let group = ""; - let current: FileInfo | null = null; +function buildPage(): HTMLElement { + let current: Pick | null = null; let query = ""; let dispose = (): void => {}; + let logicMounted = false; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -75,6 +55,11 @@ function buildPage(groups: GroupInfo[]): HTMLElement { className: "m01-master__head", children: [title, search, summary, drop, save], }); + const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); + const elementView = el("div", { + className: "m01-master__elements", + children: [head, notice, body], + }); const showNotice = (nodes: (HTMLElement | string)[]): void => { notice.replaceChildren(...nodes); @@ -88,13 +73,20 @@ function buildPage(groups: GroupInfo[]): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const openFile = (file: FileInfo): void => { + const showMode = (logic: boolean): void => { + logicHost.hidden = !logic; + elementView.hidden = logic; + }; + + const openFile = (pick: Pick): void => { dispose(); - current = file; - title.textContent = `${group} · ${fileLabel(file.file)}`; - const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows; - dispose = view(body, file.file, query); - showNotice(isStale(file.file, file.version) ? [L("M01_FileStale")] : []); + showMode(false); + current = pick; + 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); + showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []); }; let searchTimer: number | undefined; @@ -112,6 +104,18 @@ function buildPage(groups: GroupInfo[]): HTMLElement { showNotice([]); }); + /** 저장·되받기 뒤 — 지금 열린 그룹의 새 판본으로 다시 그림 */ + const reloadFiles = async (): Promise => { + if (!current) return; + const pick = current; + try { + const again = (await side.refresh(pick.group)).find((f) => f.file === pick.file.file); + if (again) openFile({ ...pick, file: again }); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + } + }; + save.addEventListener("click", async () => { save.disabled = true; try { @@ -150,89 +154,27 @@ function buildPage(groups: GroupInfo[]): HTMLElement { return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again]; }; - /* --- 좌측: 그룹 탭 + 파일 --- */ - const tabs = el("div", { className: "m01-master__tabs" }); - const files = el("div", { className: "m01-master__files" }); - - const reloadFiles = async (): Promise => { - if (!group) return; - let list: FileInfo[] = []; - try { - list = await fetchFiles(group); - } catch (error) { - showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); - } - files.replaceChildren( - ...list.map((f) => { - const button = el("button", { - className: "m01-master__file", - attrs: { type: "button" }, - children: [ - el("span", { text: fileLabel(f.file) }), - el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }), - ], - }); - button.classList.toggle("is-active", f.file === current?.file); - button.addEventListener("click", () => { - files.querySelector(".is-active")?.classList.remove("is-active"); - button.classList.add("is-active"); - openFile(f); - }); - return button; - }), - ); - const again = list.find((f) => f.file === current?.file); - if (again) openFile(again); - }; - - for (const g of groups) { - const tab = el("button", { - className: "m01-master__tab", - attrs: { type: "button" }, - children: [ - el("span", { text: g.group }), - el("span", { className: "m01-master__muted", text: String(g.files) }), - ], - }); - tab.addEventListener("click", () => { - tabs.querySelector(".is-active")?.classList.remove("is-active"); - tab.classList.add("is-active"); - group = g.group; - current = null; - dispose(); - body.replaceChildren(); - title.textContent = L("M01_PickFile"); - void reloadFiles(); - }); - tabs.append(tab); - } - - const logicTab = el("button", { - className: "m01-master__tab", - attrs: { type: "button" }, - text: L("M01_LogicTab"), - }); - logicTab.addEventListener("click", () => { - tabs.querySelector(".is-active")?.classList.remove("is-active"); - logicTab.classList.add("is-active"); - group = ""; - current = null; + /* --- 좌측: 컨테이너 여덟 --- */ + const side = buildSide(openFile, () => { dispose(); - files.replaceChildren(); - body.replaceChildren(); - title.textContent = L("M01_LogicTab"); - void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(body)); + dispose = (): void => {}; + current = null; + showMode(true); + if (logicMounted) return; + logicMounted = true; + void import("./M01_MasterData_UI_Logic_Page").then((m) => + m.mountM01Logic(logicHost, side.logicHost), + ); }); - tabs.append(logicTab); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { className: "ui-workflow-layout__main", - children: [el("div", { className: "m01-master__panel", children: [head, notice, body] })], + children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })], }); const overlays = createWorkflowOverlays({ title: L("M01_Title"), - optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }), + optionsContent: el("div", { className: "m01-master__side", children: [side.root] }), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); diff --git a/M01_MasterData/M01_MasterData_UI_Pick.ts b/M01_MasterData/M01_MasterData_UI_Pick.ts index e749af58..712f9264 100644 --- a/M01_MasterData/M01_MasterData_UI_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Pick.ts @@ -23,7 +23,7 @@ export interface PickOptions { /** 처음 찾을 글 — 그 재료 이름·규격 (없는 결과면 첫 낱말만으로 다시). */ seed: string; /** 고르면 ref · 연결 끊기 = null. */ - onPick: (ref: string | null) => void; + onPick: (ref: string | null, name?: string) => void; /** 있으면 「후보 조건으로 연결」 칸이 뜸. */ cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void }; } @@ -99,7 +99,7 @@ export function openPickModal(opt: PickOptions): void { ], }); b.addEventListener("click", () => { - opt.onPick(it.ref); + opt.onPick(it.ref, `${it.이름} ${it.규격}`.trim()); close(); }); return b; diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 017adf03..f76c25f1 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -71,8 +71,20 @@ const linkText = (link: unknown): string => ? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim() : String(link ?? ""); +/** 키 → 이름(서버가 준 것 + 방금 고른 것) — 참조 칸에 키 옆에 같이 보임 */ +const names = new Map(); +const KEY = /[A-Z]{2}\d{6}/; +const named = (text: string): string => { + const name = names.get(KEY.exec(text)?.[0] ?? ""); + return name ? `${text} · ${name}` : text; +}; +const remember = (ref: string | null, name?: string): void => { + const key = KEY.exec(ref ?? "")?.[0]; + if (key && name) names.set(key, name); +}; + /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ -export function renderRows(host: HTMLElement, file: string, q: string): () => void { +export function renderRows(host: HTMLElement, file: string, q: string, sub = ""): () => void { let page = 1; let data: RowsPage | null = null; let unlinked = false; @@ -162,7 +174,11 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo ] : []; const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" }); - add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK))); + add.addEventListener("click", () => { + const fresh = blank(d.rows[0] ?? FALLBACK); + const col = ["조사", "세부분류"].find((c) => c in fresh); + addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh); + }); const empty = d.rows.length || adds.length ? [] @@ -192,7 +208,8 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo const load = async (): Promise => { try { - data = await fetchRows(file, page, SIZE, q, unlinked); + data = await fetchRows(file, page, SIZE, q, unlinked, sub); + for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name); paint(); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); @@ -222,7 +239,7 @@ const rowBtn = (text: string): HTMLButtonElement => /** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<키>」 · 「가격정보」 줄은 원천이라 그대로). */ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { const link = show(deep(cur, SOURCE)); - const td = buildCell(link, { changed: link !== show(deep(row, SOURCE)) }); + const td = buildCell(named(link), { changed: link !== show(deep(row, SOURCE)) }); if (put && (!link || link.startsWith(NARA))) { const btn = rowBtn(L("M01_ProcureFind")); btn.addEventListener("click", () => @@ -231,7 +248,10 @@ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { title: L("M01_ProcureTitle"), current: link, seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(), - onPick: (ref) => put(withDeep(cur, SOURCE, ref)), + onPick: (ref, name) => { + remember(ref, name); + put(withDeep(cur, SOURCE, ref)); + }, }), ); td.append(btn); @@ -246,7 +266,7 @@ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { /** 품셈재료 — 가격 연결(시중물가 키 · 후보 조건 · 없음) · 고르기 모달. */ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { const link = cur["연결"]; - const td = buildCell(linkText(link), { + const td = buildCell(named(linkText(link)), { changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null), }); if (put) { @@ -260,7 +280,10 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`, current: linkText(link), seed: `${name} ${spec}`.trim(), - onPick: (ref) => put(withCell(cur, "연결", ref)), + onPick: (ref, name) => { + remember(ref, name); + put(withCell(cur, "연결", ref)); + }, cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) }, }), ); @@ -272,7 +295,7 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { /** 값 없는 인력 줄(건설업 미공표 · 준용대상)의 준용 = 공표 직종 키 고르기. */ function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { const text = show(cur["준용"]); - const td = buildCell(text, { changed: text !== show(row["준용"]) }); + const td = buildCell(named(text), { changed: text !== show(row["준용"]) }); if (put && row["값"] === null) { const btn = rowBtn(L("M01_JobFind")); btn.addEventListener("click", () => @@ -281,7 +304,10 @@ function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`, current: text, seed: "", - onPick: (ref) => put(withCell(cur, "준용", ref)), + onPick: (ref, name) => { + remember(ref, name); + put(withCell(cur, "준용", ref)); + }, }), ); td.append(btn); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts new file mode 100644 index 00000000..6b3afa43 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -0,0 +1,163 @@ +/* ============================================================================= + * M01_MasterData_UI_Side.ts + * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 여덟 개 + * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 일위대가 로직 + * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 + * ========================================================================== */ + +import { el, showToast } from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { t as L } from "@ui/ui_template_locale"; +import { fetchFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; + +/** 요소 화면에서 열 것 — 파일 · 하위 거름(조사·세부분류) · 제목 */ +export interface Pick { + group: string; + file: FileInfo; + sub: string; + label: string; +} + +export interface SideHandle { + root: HTMLElement; + /** 일위대가 로직 컨테이너 안(로직 목록이 들어갈 자리) */ + logicHost: HTMLElement; + /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ + refresh: (group: string) => Promise; + setActive: (id: string | null) => void; +} + +const LABOR_SURVEYS = [ + "건설업", + "제조업", + "엔지니어링", + "측량", + "건설사업관리", + "SW", + "산림", + "준용대상", +]; +const MACHINE_KINDS = ["건설품셈", "산림품셈"]; +const MATERIAL_NAMES: Record = { 나라장터자재: "나라장터", 오피넷유가: "유가" }; + +/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */ +export const fileLabel = (file: string): string => + file.replace(/^[^_]+_/, "").replace(/\.json$/, ""); + +const GROUP_TITLE = { + 인력: "M01_GroupLabor", + 재료: "M01_GroupMaterial", + 기계: "M01_GroupMachine", + 소요량: "M01_GroupQuantity", + 계수: "M01_GroupFactor", + 환율: "M01_GroupExchange", + 요율: "M01_GroupRate", +} as const; + +type Group = keyof typeof GROUP_TITLE; +const GROUPS = Object.keys(GROUP_TITLE) as Group[]; +const LEAF: Group[] = ["환율", "요율"]; + +export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { + const lists = new Map(); + const bodies = new Map(); + const buttons = new Map(); + + const setActive = (id: string | null): void => { + for (const [key, button] of buttons) button.classList.toggle("is-active", key === id); + }; + + const item = (id: string, text: string, count: number | null, run: () => void): HTMLElement => { + const button = el("button", { + className: "m01-side__item", + attrs: { type: "button" }, + children: [ + el("span", { text }), + ...(count === null + ? [] + : [el("span", { className: "m01-side__muted", text: count.toLocaleString("ko-KR") })]), + ], + }); + buttons.set(id, button); + button.addEventListener("click", () => { + setActive(id); + run(); + }); + return button; + }; + + const drawGroup = (group: Group): void => { + const files = lists.get(group) ?? []; + const body = bodies.get(group); + if (!body) return; + const open = (file: FileInfo, sub: string, label: string): void => + onOpen({ group, file, sub, label }); + const one = files[0]; + if (group === "인력" || group === "기계") { + const kinds = group === "인력" ? LABOR_SURVEYS : MACHINE_KINDS; + body.replaceChildren( + ...(one + ? [ + item(`${group}\n`, L("M01_All"), one.rows, () => open(one, "", L("M01_All"))), + ...kinds.map((k) => item(`${group}\n${k}`, k, null, () => open(one, k, k))), + ] + : []), + ); + return; + } + body.replaceChildren( + ...files.map((f) => { + const name = fileLabel(f.file); + const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; + return item(`${group}\n${f.file}`, label, f.rows, () => open(f, "", label)); + }), + ); + }; + + const refresh = async (group: string): Promise => { + const list = await fetchFiles(group); + lists.set(group as Group, list); + drawGroup(group as Group); + return list; + }; + + const section = (title: string, body: HTMLElement | null): HTMLElement => + el("section", { + className: "m01-side__group ui-collapsible ui-sidebar-section is-collapsed", + children: [ + el("h3", { className: "ui-collapsible__title", text: title }), + ...(body ? [body] : []), + ], + }); + + const groups = GROUPS.map((group) => { + const body = LEAF.includes(group) ? null : el("div", { className: "m01-side__items" }); + if (body) bodies.set(group, body); + const root = section(L(GROUP_TITLE[group]), body); + if (!body) { + root.classList.add("m01-side__leaf"); + buttons.set(`${group}\n`, root); + root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { + const one = lists.get(group)?.[0]; + if (!one) return; + setActive(`${group}\n`); + onOpen({ group, file: one, sub: "", label: L(GROUP_TITLE[group]) }); + }); + } + return root; + }); + + const logicHost = el("div", { className: "m01-side__items" }); + const logic = section(L("M01_GroupLogic"), logicHost); + logic.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { + setActive(null); + onLogic(); + }); + + const root = el("div", { className: "m01-side", children: [...groups, logic] }); + attachCollapsible(root); + void Promise.all(GROUPS.map(refresh)).catch((error) => + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), + ); + return { root, logicHost, refresh, setActive }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 35cc3d74..2499d41b 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -4,33 +4,53 @@ } .m01-master__side { - display: flex; - flex-direction: column; - gap: var(--spacing-8); padding-top: var(--spacing-8); } -.m01-master__tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-4); +/* 왼쪽 패널 — 공용 접기 컨테이너 여덟 */ +.m01-side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); } -.m01-master__files { +.m01-side__group { + padding: calc(var(--spacing-8) + var(--spacing-4)); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m01-side__group > h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.m01-side__leaf > h3::after { + display: none; +} + +.m01-side__leaf.is-active > h3 { + color: var(--color-accent); +} + +.m01-side__items { display: flex; flex-direction: column; gap: 2px; + max-height: 50vh; + margin-top: var(--spacing-8); + overflow: auto; } -.m01-master__tab, -.m01-master__file { +.m01-side__item { display: flex; justify-content: space-between; gap: var(--spacing-8); padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); + border: 0; border-radius: var(--radius-buttons); - background: var(--color-surface); + background: transparent; color: var(--color-text-body); font: inherit; font-size: var(--text-body-sm); @@ -38,22 +58,16 @@ cursor: pointer; } -.m01-master__file { - border-color: transparent; - background: transparent; -} - -.m01-master__tab:hover, -.m01-master__file:hover { +.m01-side__item:hover { background: var(--color-paper); } -.m01-master__tab.is-active, -.m01-master__file.is-active { +.m01-side__item.is-active { background: var(--color-mist-violet); color: var(--color-accent); } +.m01-side__muted, .m01-master__muted { color: var(--color-text-muted); font-size: var(--text-caption); diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts index f70b3920..482a8253 100644 --- a/M01_MasterData/M01_MasterData_UI_Tables.ts +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -26,7 +26,7 @@ import { const LIST_SIZE = 30; /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ -export function renderTables(host: HTMLElement, file: string, q: string): () => void { +export function renderTables(host: HTMLElement, file: string, q: string, _sub = ""): () => void { let stop = (): void => {}; const list = (page = 1): void => { stop(); diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 21e13fe2..797f9a8c 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -304,3 +304,31 @@ def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClien one = _get(client, "/api/m01/logic", key=key) power = one["prices"][key_of("MP:전력")] assert power["값"] > 0 and power["출처"].startswith(key_of("MT:M0005250064") + ".") + + +def test_하위_거름과_참조_이름(client: TestClient) -> None: + every = _get(client, "/api/m01/rows", file=LABOR, size=500) + survey = _get(client, "/api/m01/rows", file=LABOR, size=500, sub="측량") + assert 0 < survey["total"] < every["total"] and {r["조사"] for r in survey["rows"]} == {"측량"} + machine = _get(client, "/api/m01/rows", file="기계.json", size=500, sub="산림품셈") + assert 0 < machine["total"] < 100 and {r["세부분류"] for r in machine["rows"]} == {"산림품셈"} + linked = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500) + keyed = [r for r in linked["rows"] if isinstance(r["연결"], str)] + assert keyed and all(linked["refs"][r["연결"]] for r in keyed) + + +def test_시중물가_저장은_띄어쓰기를_안_바꿈(client: TestClient) -> None: + market = "재료_시중물가.json" + old = (store.FOLDER / market).read_text(encoding="utf-8").split("\n") + rows = _get(client, "/api/m01/rows", file=market, size=1) + row = rows["rows"][0] + row["값"]["시중"]["가격정보"] = 1 + change = {"op": "edit", "key": row["키"], "row": row} + body = {"files": [{"file": market, "version": rows["version"], "changes": [change]}]} + assert client.post("/api/m01/save", json=body).status_code == 200 + new = (store.FOLDER / market).read_text(encoding="utf-8").split("\n") + diff = [(a, b) for a, b in zip(old, new) if a != b] + assert len(new) == len(old) and len(diff) == 1 + assert diff[0][1].replace('"가격정보": 1,', '"가격정보": X,').count("{ ") == diff[0][0].count( + "{ " + ) diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index acaa66ea..40ec02f6 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -55,4 +55,13 @@ export const ui_locales_m1 = { M01_JobFind: ["직종 고르기", "Pick job"], M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"], M01_JobCol: ["준용", "Substitute"], + M01_All: ["전체", "All"], + M01_GroupLabor: ["인력", "Labor"], + M01_GroupMaterial: ["재료", "Materials"], + M01_GroupMachine: ["기계", "Machines"], + M01_GroupQuantity: ["소요량", "Quantities"], + M01_GroupFactor: ["계수", "Factors"], + M01_GroupExchange: ["환율", "Exchange rates"], + M01_GroupRate: ["요율", "Rates"], + M01_GroupLogic: ["일위대가 로직", "Unit-cost logic"], } as const;