feat(M01): B01 마스터 데이터 단추 하나로 · 시중물가 「조달 찾기」 모달 · 찾기 API

- B01 옛 「마스터 데이터」(Z01) 단추 제거 · 「마스터 요소」 이름을 「마스터 데이터」 로
- 재료 › 시중물가 줄마다 「조달 찾기」 — 나라장터자재 + 시중물가 조달 줄을 이름·규격으로 찾아 「나라장터:<열쇠>」 연결 · 노랑 표시 · 연결 끊기
- GET /api/m01/procurement 추가 · 값 묶음(조달{…}) 확정 전이라 연결은 조달›연결 칸에 임시로 담음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-19 23:29:17 +09:00
co-authored by Claude Sonnet 5
parent dd120ce44b
commit f80046e4ae
9 changed files with 203 additions and 8 deletions
-5
View File
@@ -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",
@@ -107,3 +107,14 @@ export async function saveFiles(files: FileChanges[]): Promise<SaveResult> {
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 });
+5
View File
@@ -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)
+16
View File
@@ -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` 에 더함)."""
@@ -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<void> => {
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();
}
+27 -2
View File
@@ -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);
}
@@ -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;
}
+7
View File
@@ -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["이름"]
+7 -1
View File
@@ -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;