diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 6e47a210..1ddbe343 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -134,11 +134,6 @@ function buildSystemSettingsPanel(): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__actions"; wrap.append( - createButton({ - label: L("B01_Dashboard_MasterData"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.Z01_MASTER_DATA), - }), createButton({ label: L("B01_Dashboard_MasterElements"), variant: "ghost", diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index 76e11db9..89a2e6b9 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -107,3 +107,14 @@ export async function saveFiles(files: FileChanges[]): Promise { if (status === 422 && typeof detail === "object") return { status, errors: detail.errors ?? [] }; return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) }; } + +export interface ProcureItem { + ref: string; + 이름: string; + 규격: string; + 단위: string; + 값: unknown; +} + +export const fetchProcurement = (q: string): Promise<{ total: number; items: ProcureItem[] }> => + get("/procurement", { q, limit: 50 }); diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 0746de12..9089047d 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -98,6 +98,11 @@ def get_elements(group: str, q: str = "", limit: int = 50) -> dict: return _call(store.elements, group, q, limit) +@router.get("/procurement") +def get_procurement(q: str = "", limit: int = 50) -> dict: + return _call(store.procurement, q, limit) + + @router.post("/calc") def post_calc(body: CalcBody) -> dict: return _call(store.calc, body.book, body.key, body.inputs, body.row, body.file) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 215de7a4..ea64ceac 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -224,6 +224,22 @@ def elements(group: str, q: str, limit: int) -> dict: return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} +def procurement(q: str, limit: int) -> dict: + """조달 찾기 창 — 나라장터자재 + 시중물가 안의 조달 줄에서 이름·규격 찾기 · `ref` = 「나라장터:<열쇠>」.""" + words = q.lower().split() + hits = [] + for book, only_procured in (("나라장터자재", False), ("시중물가", True)): + data, _ = read(f"재료_{book}.json") + for row in data.get("줄") or []: + if only_procured and "조달" not in row: + continue + text = f"{row.get('이름', '')} {row.get('규격', '')}".lower() + if all(w in text for w in words): + brief = {k: row.get(k) for k in ("이름", "규격", "단위", "값")} + hits.append({"ref": f"{book}:{row.get('열쇠')}", **brief}) + return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} + + # ── 시험 계산 ────────────────────────────────────────────────────────── def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None: """저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함).""" diff --git a/M01_MasterData/M01_MasterData_UI_Procure.ts b/M01_MasterData/M01_MasterData_UI_Procure.ts new file mode 100644 index 00000000..79212869 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Procure.ts @@ -0,0 +1,86 @@ +/* ============================================================================= + * M01_MasterData_UI_Procure.ts + * 재료 › 시중물가 「조달 찾기」 모달 — 조달 자료를 이름·규격으로 찾아 고르면 ref 를 돌려줌 + * ========================================================================== */ + +import { el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { fetchProcurement, type ProcureItem } from "./M01_MasterData_Api_Fetch"; +import { show } from "./M01_MasterData_UI_Cells"; + +const button = (text: string): HTMLButtonElement => + el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } }); + +/** 고르면 `onPick(ref)` · 연결 끊기 = `onPick(null)`. */ +export function openProcureModal(current: string, onPick: (ref: string | null) => void): void { + const close = (): void => back.remove(); + const input = el("input", { + className: "m01-master__search", + attrs: { type: "search", placeholder: L("M01_ProcureSearch") }, + }); + const list = el("div", { className: "m01-procure__list" }); + const cut = button(L("M01_ProcureCut")); + cut.disabled = !current; + cut.addEventListener("click", () => { + onPick(null); + close(); + }); + const shut = button(L("M01_ProcureClose")); + shut.addEventListener("click", close); + const box = el("div", { + className: "m01-procure__box", + children: [ + el("h3", { text: L("M01_ProcureTitle") }), + el("p", { className: "m01-master__muted", text: current }), + input, + list, + el("div", { className: "m01-procure__foot", children: [cut, shut] }), + ], + }); + const back = el("div", { className: "m01-procure", children: [box] }); + back.addEventListener("click", (e) => { + if (e.target === back) close(); + }); + document.body.append(back); + + const paint = (items: ProcureItem[], total: number): void => { + list.replaceChildren( + ...(items.length + ? items.map((it) => { + const b = el("button", { + className: "m01-procure__item", + attrs: { type: "button" }, + children: [ + el("strong", { text: it.이름 }), + el("span", { text: `${it.규격} · ${it.단위} · ${show(it.값)}` }), + el("span", { className: "m01-master__muted", text: it.ref }), + ], + }); + b.addEventListener("click", () => { + onPick(it.ref); + close(); + }); + return b; + }) + : [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })]), + ...(total > items.length + ? [el("p", { className: "m01-master__muted", text: `${items.length} / ${total}` })] + : []), + ); + }; + let timer: number | undefined; + const run = async (): Promise => { + try { + const r = await fetchProcurement(input.value.trim()); + paint(r.items, r.total); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + } + }; + input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void run(), 300); + }); + void run(); + input.focus(); +} diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 93f346b9..1bcbb129 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -5,6 +5,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; +import { openProcureModal } from "./M01_MasterData_UI_Procure"; import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch"; import { addRow, @@ -22,11 +23,14 @@ import { coerce, flatten, isScalar, + SEP, show, withCell, } from "./M01_MasterData_UI_Cells"; const SIZE = 50; +const MARKET = "재료_시중물가.json"; +const LINK = `조달${SEP}연결`; const FALLBACK: Row = { 열쇠: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" }; /** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */ @@ -51,9 +55,16 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo const adds = draft?.adds ?? []; const cols: string[] = []; for (const row of [...adds, ...d.rows]) { - for (const k of Object.keys(flatten(row))) if (!cols.includes(k)) cols.push(k); + for (const k of Object.keys(flatten(row))) if (k !== LINK && !cols.includes(k)) cols.push(k); } - const head = el("tr", { children: [el("th"), ...cols.map((c) => el("th", { text: c }))] }); + const market = file === MARKET; + const head = el("tr", { + children: [ + el("th"), + ...cols.map((c) => el("th", { text: c })), + ...(market ? [el("th", { text: L("M01_ProcureCol") })] : []), + ], + }); const body = el("tbody"); adds.forEach((row, i) => { @@ -96,6 +107,20 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo }), ); } + if (market) { + const link = String(now[LINK] ?? ""); + const pick = (ref: string | null): void => + editRow(file, d.version, key, row, withCell(cur, LINK, ref)); + const btn = el("button", { + className: "m01-master__row-btn", + text: L("M01_ProcureFind"), + attrs: { type: "button" }, + }); + btn.addEventListener("click", () => openProcureModal(link, pick)); + const td = buildCell(link, { changed: link !== String(orig[LINK] ?? "") }); + td.append(btn); + tr.append(td); + } body.append(tr); } diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index ef501483..e4e0296f 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -217,3 +217,47 @@ .m01-master__empty { color: var(--color-text-muted); } + +/* 조달 찾기 모달 */ +.m01-procure { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} +.m01-procure__box { + display: flex; + flex-direction: column; + gap: 8px; + width: min(640px, 92vw); + max-height: 80vh; + padding: 16px; + border-radius: 8px; + background: var(--color-surface, #fff); +} +.m01-procure__list { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + min-height: 120px; +} +.m01-procure__item { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 6px 8px; + border: 1px solid var(--color-border, #ccc); + border-radius: 4px; + background: transparent; + cursor: pointer; + text-align: left; +} +.m01-procure__foot { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index d2ffd7e4..cd5027df 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -240,3 +240,10 @@ def test_저장은_고친_줄만_바꿈(client: TestClient) -> None: def test_main_은_시스템관리자만() -> None: main = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8") assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main + + +def test_조달_찾기는_나라장터자재를_이름_규격으로_찾음(client: TestClient) -> None: + body = _get(client, "/api/m01/procurement", q="육각볼트 M6*20") + assert body["total"] >= 1 + item = body["items"][0] + assert item["ref"].startswith("나라장터자재:") and "육각볼트" in item["이름"] diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index d19b5f78..b421a263 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -4,7 +4,7 @@ * ========================================================================== */ export const ui_locales_m1 = { - B01_Dashboard_MasterElements: ["마스터 요소", "Master Elements"], + B01_Dashboard_MasterElements: ["마스터 데이터", "Master Data"], M01_LogicTab: ["로직", "Logic"], M01_Title: ["마스터 요소", "Master Elements"], M01_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], @@ -37,4 +37,10 @@ export const ui_locales_m1 = { "This file changed after you started editing — saving will be refused", ], M01_CheckErrors: ["검사에 걸림", "Validation failed"], + M01_ProcureFind: ["조달 찾기", "Find procurement"], + M01_ProcureTitle: ["조달 자료 찾기", "Find procurement data"], + M01_ProcureSearch: ["이름·규격 찾기", "Find by name or spec"], + M01_ProcureCut: ["연결 끊기", "Unlink"], + M01_ProcureClose: ["닫기", "Close"], + M01_ProcureCol: ["조달 연결", "Procurement link"], } as const;