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