From e41855d76aa6c1a980961303c4540908f9ac91e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 17:00:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=86=8C=EC=9A=94=EB=9F=89=C2=B7?= =?UTF-8?q?=EA=B3=84=EC=88=98=C2=B7=EB=A1=9C=EC=A7=81=C2=B7=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B8=EB=A0=A5=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EB=AA=A8?= =?UTF-8?q?=EC=96=91=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?+=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B1=B0=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=8A=B8=EB=A6=AC=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 왼쪽: 「전체」 한 줄 + 구분 → 상세구분 공용 드롭다운(기계는 세부분류 한 단) · 목록은 서버 /subs - 가운데: 표 목록(키·원문번호·구분·상세구분·이름·기준·출처·비고) · 로직은 파일을 가로질러 한 목록 · 쪽 나눔 · 누르면 상세 - 표 목록 응답에 비고 칸 추가 · 관급 시험을 조달청 값 이은 줄 기준으로 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 1 + M01_MasterData/M01_MasterData_Store.py | 13 +- .../M01_MasterData_UI_Logic_List.ts | 183 +++++++---------- .../M01_MasterData_UI_Logic_Page.ts | 40 +++- .../M01_MasterData_UI_Logic_Style.css | 54 +---- .../M01_MasterData_UI_Logic_Text.ts | 2 +- M01_MasterData/M01_MasterData_UI_Page.ts | 4 +- M01_MasterData/M01_MasterData_UI_Side.ts | 187 +++++++----------- M01_MasterData/M01_MasterData_UI_Style.css | 22 +-- M01_MasterData/M01_MasterData_UI_Tables.ts | 50 ++--- resources/tester/test_m01_api.py | 5 +- ui_template/ui_template_locale_m1.ts | 1 + 12 files changed, 226 insertions(+), 336 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index f56c3477..df3e436e 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -50,6 +50,7 @@ export interface TableHead { 이름: string; 기준: string; 출처: string; + 비고?: string; 조건: Record; 값칸: Record; count: number; diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 80015543..1d8f7b26 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -185,7 +185,18 @@ def rows( } -_TABLE_HEADS = ("키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "조건", "값칸") +_TABLE_HEADS = ( + "키", + "원문번호", + "구분", + "상세구분", + "이름", + "기준", + "출처", + "비고", + "조건", + "값칸", +) def tables( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index d2625a0a..6f41ab8c 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -1,12 +1,12 @@ /* ============================================================================= * M01_MasterData_UI_Logic_List.ts - * 왼쪽 로직 목록 — 구분 · 상세구분 · 이름 찾기 · 막힘만 · 막힘(⛔)·고침(●) 표시 - * 거르기는 화면에서(목록은 한 번 받음 · 391 줄 남짓) + * 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기 + 막힘만 + * 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음) * ========================================================================== */ import { createInputField, el } from "@ui/ui_template_elements"; -import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree"; -import type { LogicSummary, SubBrief } from "./M01_MasterData_UI_Logic_Api"; +import { buildPager } from "./M01_MasterData_UI_Cells"; +import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; export type ListMark = "edited" | "new" | "deleted"; @@ -18,141 +18,100 @@ export interface ListItem { key: string; number: string; name: string; + unit: string; + source: string; blocked: boolean; reasons: string[]; } export interface ListHandle { root: HTMLElement; - setItems: (items: LogicSummary[], subs: SubBrief[]) => void; + setItems: (items: LogicSummary[]) => void; /** 저장 안 한 새 로직도 목록 맨 위에 */ setMarks: (marks: Map, extra: ListItem[]) => void; - setActive: (id: string | null) => void; + /** 왼쪽 거름이 고른 범위 — 비면 전체 */ + setPick: (sub: string, detail: string) => void; + total: () => number; } export const logicId = (sub: string, key: string): string => `${sub}\n${key}`; +const SIZE = 30; +const COLS = ["키", "원문번호", "구분", "상세구분", "이름", "결과단위", "출처", ""] as const; + export function buildList(onOpen: (item: ListItem) => void): ListHandle { let items: ListItem[] = []; - let subs: SubBrief[] = []; let extra: ListItem[] = []; let marks = new Map(); - let active: string | null = null; - /** 트리에서 고른 범위 — 구분 · 상세구분(비면 전체) */ + let page = 1; const pick = { sub: "", detail: "" }; - const tree = el("div", { className: "m01-logic__tree" }); const searchField = createInputField({ type: "search", placeholder: tx("List_Search") }); const search = searchField.input; const blockedOnly = el("input", { attrs: { type: "checkbox" } }); - const count = el("span", { className: "m01-logic__muted" }); - const list = el("ul", { className: "m01-logic__list" }); - - const inPick = (x: ListItem): boolean => - (!pick.sub || x.sub === pick.sub) && (!pick.detail || x.detail === pick.detail); - - const make: MakeRow = (node, caret, depth, onClick) => { - const button = el("button", { - className: `m01-side__item${node.id === pickId ? " is-active" : ""}`, - attrs: { type: "button" }, - children: [ - el("span", { - children: [ - el("span", { className: "m01-tree__caret", text: caret }), - el("span", { text: node.label }), - ], - }), - el("span", { className: "m01-side__muted", text: String(node.count ?? "") }), - ], - }); - button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; - button.addEventListener("click", () => { - onClick(); - for (const b of tree.querySelectorAll(".is-active")) b.classList.remove("is-active"); - button.classList.add("is-active"); - }); - return button; - }; - let pickId = ""; - /** 전체 › 구분 › 상세구분 — 눌러 범위를 고름(갈래 목록은 서버가 준 것) */ - const drawTree = (): void => { - const count = (f: (x: ListItem) => boolean): number => items.filter(f).length; - const set = (id: string, sub: string, detail: string): void => { - pickId = id; - Object.assign(pick, { sub, detail }); - draw(); - }; - const nodes: TreeNode[] = [ - { - id: "all", - label: tx("List_AllBooks"), - count: items.length, - open: true, - 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)); - }; + const grid = el("div", { className: "m01-master__grid-wrap" }); const draw = (): void => { const q = search.value.trim().toLowerCase(); const shown = [...extra, ...items].filter( (x) => - inPick(x) && + (!pick.sub || x.sub === pick.sub) && + (!pick.detail || x.detail === pick.detail) && (!blockedOnly.checked || x.blocked) && (!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)); + page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); + const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line); + pager.replaceWith((pager = buildPager(shown.length, SIZE, page, go))); + grid.replaceChildren( + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: COLS.map((c) => el("th", { text: c })) })], + }), + el("tbody", { children: rows }), + ], + }), + ); + }; + const go = (p: number): void => { + page = p; + draw(); }; const line = (item: ListItem): HTMLElement => { const mark = marks.get(item.id); - const badges: HTMLElement[] = []; - if (item.blocked) { - badges.push( - el("span", { - className: "m01-logic__badge m01-logic__badge--blocked", - text: `⛔ ${tx("List_Blocked")}`, - attrs: { title: item.reasons.join("\n") }, - }), - ); - } - if (mark) { - const label = { edited: "List_Edited", new: "List_New", deleted: "List_Deleted" } as const; - badges.push(el("span", { className: "m01-logic__badge", text: `● ${tx(label[mark])}` })); - } - const button = el("button", { - className: `m01-logic__item${item.id === active ? " is-active" : ""}`, - attrs: { type: "button", title: item.key }, + const state = [ + item.blocked ? `⛔ ${tx("List_Blocked")}` : "", + mark + ? `● ${tx(({ edited: "List_Edited", new: "List_New", deleted: "List_Deleted" } as const)[mark])}` + : "", + ] + .filter(Boolean) + .join(" "); + const tr = el("tr", { + className: `m01-master__table-row${mark ? " is-changed" : ""}`, children: [ - el("span", { - className: "m01-logic__muted", - text: `${item.sub} ${item.detail} · ${item.number}`, - }), - el("span", { text: item.name || item.number || item.key }), - el("span", { className: "m01-logic__badges", children: badges }), - ], + item.key, + item.number, + item.sub, + item.detail, + item.name, + item.unit, + item.source, + state, + ].map((v) => el("td", { text: v, attrs: { title: v } })), }); - button.addEventListener("click", () => onOpen(item)); - return el("li", { children: [button] }); + if (item.blocked) tr.title = item.reasons.join("\n"); + tr.addEventListener("click", () => onOpen(item)); + return tr; }; - search.addEventListener("input", draw); - blockedOnly.addEventListener("change", draw); + search.addEventListener("input", () => go(1)); + blockedOnly.addEventListener("change", () => go(1)); + let pager = buildPager(0, SIZE, 1, go); - const root = el("aside", { - className: "m01-logic__side", + const root = el("div", { + className: "m01-logic__listview", children: [ el("div", { className: "m01-logic__filters", @@ -162,17 +121,15 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { className: "m01-logic__check", children: [blockedOnly, el("span", { text: tx("List_BlockedOnly") })], }), - count, + pager, ], }), - list, + grid, ], }); - root.prepend(tree); return { root, - setItems: (logics, kinds) => { - subs = kinds; + setItems: (logics) => { items = logics.map((x) => ({ id: logicId(x.구분, x.키), sub: x.구분, @@ -180,10 +137,11 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { key: x.키, number: x.원문번호, name: x.이름, + unit: x.결과단위, + source: x.출처, blocked: x.blocked, reasons: x.reasons, })); - drawTree(); draw(); }, setMarks: (next, more) => { @@ -191,9 +149,10 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { extra = more; draw(); }, - setActive: (id) => { - active = id; - draw(); + setPick: (sub, detail) => { + Object.assign(pick, { sub, detail }); + go(1); }, + total: () => items.length, }; } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index 786dd09b..0943779b 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -7,6 +7,7 @@ * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ +import { t as L } from "@ui/ui_template_locale"; import { createButton, el, @@ -32,6 +33,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; +import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Logic_Style.css"; @@ -66,7 +68,7 @@ function loadDrafts(): Record { } } -export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): Promise { +export async function mountM01Logic(host: HTMLElement, side: SideHandle): Promise { let drafts = loadDrafts(); let files: LogicFile[] = []; let opened: Opened | null = null; @@ -75,8 +77,10 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P const editor = el("div", { className: "m01-logic__editor" }); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("aside", { className: "m01-logic__calc" }); + const filterHost = el("div"); const pending = el("span", { className: "m01-logic__muted" }); const list = buildList((item) => void open(item.id, item.sub, item.key)); + const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) }); const persist = (): void => { try { @@ -96,6 +100,8 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P key: d.row.키, number: d.row.원문번호, name: d.row.이름, + unit: d.row.결과단위, + source: d.row.출처, blocked: false, reasons: [], }); @@ -146,11 +152,11 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P }; const drawEditor = (): void => { - list.setActive(opened?.id ?? null); - if (!opened) { - editor.replaceChildren(el("p", { className: "m01-logic__empty", text: tx("Empty") })); - return; - } + // 고른 로직이 없으면 목록 · 있으면 편집 + 시험 계산 + const listing = !opened; + list.root.hidden = !listing; + editor.hidden = calc.hidden = back.hidden = listing; + if (!opened) return; if (!opened.row) { editor.replaceChildren( el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }), @@ -221,7 +227,22 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P fetchLogicSubs(), ]); files = logicFiles; - list.setItems(logics, subs); + list.setItems(logics); + // 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만 + filterHost.replaceChildren( + side.filter({ + id: "로직|", + store: "m01.filter.로직", + subs, + total: list.total(), + subLabel: L("M01_LaborSub"), + detailLabel: L("M01_LaborDetail"), + onPick: (sub, detail) => { + list.setPick(sub, detail); + if (opened) show(null); + }, + }), + ); persist(); }; @@ -348,6 +369,7 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P const bar = el("div", { className: "m01-logic__bar", children: [ + back, el("h2", { text: tx("Title") }), pending, createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }), @@ -360,12 +382,12 @@ export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): P el("div", { className: "m01-logic", children: [ - el("main", { className: "m01-logic__main", children: [bar, errors, editor] }), + el("main", { className: "m01-logic__main", children: [bar, errors, list.root, editor] }), calc, ], }), ); - listHost.replaceChildren(list.root); + side.logicHost.replaceChildren(filterHost); 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 b4477c1d..c786f157 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -1,4 +1,4 @@ -/* M01 로직 칸 — 왼쪽 목록 / 가운데 일위대가표 / 오른쪽 시험 계산 */ +/* M01 로직 칸 — 가운데 로직 목록 · 일위대가표 / 오른쪽 시험 계산 (거름은 왼쪽 패널) */ .m01-logic [hidden] { display: none !important; } @@ -15,7 +15,6 @@ font-size: var(--text-body-sm); } -.m01-logic__side, .m01-logic__main, .m01-logic__calc { display: flex; @@ -43,52 +42,6 @@ gap: var(--spacing-4); } -.m01-logic__list { - margin: 0; - padding: 0; - list-style: none; - overflow: auto; -} - -.m01-logic__item { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - width: 100%; - padding: var(--spacing-4) var(--spacing-8); - border: 0; - border-radius: var(--radius-buttons); - background: none; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.m01-logic__item:hover { - background: var(--color-paper); -} - -.m01-logic__item.is-active { - background: var(--color-mist-violet); - color: var(--color-accent); -} - -.m01-logic__badges { - display: flex; - gap: var(--spacing-4); -} - -.m01-logic__badge { - font-size: var(--text-caption); - color: var(--color-warning); -} - -.m01-logic__badge--blocked { - color: var(--color-danger); -} - .m01-logic__bar { display: flex; align-items: center; @@ -385,8 +338,3 @@ flex-wrap: wrap; gap: var(--spacing-4); } - -/* 왼쪽 패널 컨테이너 안 로직 목록 */ -.m01-side__items .m01-logic__list { - max-height: 40vh; -} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index 1b1dbe1d..1126a01d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -16,12 +16,12 @@ const TEXT = { List_Edited: ["고침", "Edited"], List_New: ["새것", "New"], List_Deleted: ["지움", "Deleted"], + Bar_Back: ["← 로직 목록", "← Logics"], Bar_New: ["로직 새로 만들기", "New logic"], Bar_Delete: ["로직 지우기", "Delete logic"], Bar_Save: ["저장", "Save"], Bar_Discard: ["고친 것 버리기", "Discard edits"], Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"], - Empty: ["왼쪽 목록에서 로직을 고르세요", "Pick a logic from the list"], Head_File: ["파일", "File"], Head_Key: ["키", "Key"], Head_Number: ["원문번호", "Source no."], diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 29b29d93..08799466 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -166,9 +166,7 @@ function buildPage(): HTMLElement { showMode(true); if (logicMounted) return; logicMounted = true; - void import("./M01_MasterData_UI_Logic_Page").then((m) => - m.mountM01Logic(logicHost, side.logicHost), - ); + void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side)); }); const layout = el("div", { className: "ui-workflow-layout m01-master" }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 38ff9812..310013bc 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -8,14 +8,8 @@ import { createSelectField, 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, - fetchRows, - fetchSubs, - type FileInfo, - type SubInfo, -} from "./M01_MasterData_Api_Fetch"; -import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree"; +import { fetchFiles, fetchSubs, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; +import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; /** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */ export interface Pick { @@ -33,10 +27,23 @@ export interface SideHandle { /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; setActive: (id: string | null) => void; + /** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */ + filter: (args: FilterArgs) => HTMLElement; +} + +export interface FilterArgs { + /** 「전체」 줄 눌림 표시용 */ + id: string; + /** 고른 것을 기억할 sessionStorage 칸 */ + store: string; + subs: SubInfo[]; + total: number; + subLabel: string; + /** 없으면 한 단 */ + detailLabel?: string; + onPick: (sub: string, detail: string, label: string) => void; } -const LABOR_FILTER = "m01.laborFilter"; -const LABOR_ID = "인력|"; const MATERIAL_NAMES: Record = { 오피넷유가: "유가" }; /** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ const MATERIAL_ORDER = ["자재품목", "오피넷유가", "품셈재료"]; @@ -63,7 +70,6 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const lists = new Map(); const bodies = new Map(); const buttons = new Map(); - const counts = new Map(); // 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로 const kinds = new Map(); @@ -102,64 +108,68 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si return button; }; - /** 인력 — 「전체」 한 줄 + 구분 → 상세구분 두 단 거름(서버가 준 목록 · 고른 것은 기억) */ - const laborFilter = ( - file: FileInfo, - open: (file: FileInfo, sub: string, label: string, detail?: string) => void, - ): HTMLElement => { - const list = kinds.get("인력") ?? []; + /** 「전체」 한 줄 + 구분 → 상세구분 드롭다운 거름(목록은 서버가 준 것 · 고른 것은 기억) — + * detailLabel 없으면 한 단 */ + const filter = (a: FilterArgs): HTMLElement => { const saved = (() => { try { - return JSON.parse(sessionStorage.getItem(LABOR_FILTER) ?? "[]") as string[]; + return JSON.parse(sessionStorage.getItem(a.store) ?? "[]") as string[]; } catch { return []; } })(); - const pickSub = list.find((k) => k.name === saved[0]); + const pickSub = a.subs.find((k) => k.name === saved[0]); const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); const sub = createSelectField({ - options: opts(list.map((k) => k.name)), + options: opts(a.subs.map((k) => k.name)), value: pickSub?.name ?? "", compact: true, - label: L("M01_LaborSub"), - }); - const detail = createSelectField({ - options: opts(pickSub?.details ?? []), - value: saved[1] ?? "", - compact: true, - label: L("M01_LaborDetail"), - disabled: !pickSub?.details.length, + label: a.subLabel, }); + const detail = a.detailLabel + ? createSelectField({ + options: opts(pickSub?.details ?? []), + value: saved[1] ?? "", + compact: true, + label: a.detailLabel, + disabled: !pickSub?.details.length, + }) + : null; const run = (): void => { + const d = detail?.select.value ?? ""; try { - sessionStorage.setItem( - LABOR_FILTER, - JSON.stringify([sub.select.value, detail.select.value]), - ); + sessionStorage.setItem(a.store, JSON.stringify([sub.select.value, d])); } catch { /* 기억 못 해도 거름은 됨 */ } - const label = - [sub.select.value, detail.select.value].filter(Boolean).join(" · ") || L("M01_All"); - setActive(LABOR_ID); - open(file, sub.select.value, label, detail.select.value); + setActive(a.id); + a.onPick( + sub.select.value, + d, + [sub.select.value, d].filter(Boolean).join(" · ") || L("M01_All"), + ); }; sub.select.addEventListener("change", () => { - const details = list.find((k) => k.name === sub.select.value)?.details ?? []; - detail.setOptions(opts(details), ""); - detail.select.disabled = details.length === 0; + if (detail) { + const details = a.subs.find((k) => k.name === sub.select.value)?.details ?? []; + detail.setOptions(opts(details), ""); + detail.select.disabled = details.length === 0; + } run(); }); - detail.select.addEventListener("change", run); - const all = make({ id: LABOR_ID, label: L("M01_All"), count: file.rows, run }, "", 0, () => { + detail?.select.addEventListener("change", run); + const all = make({ id: a.id, label: L("M01_All"), count: a.total, run }, "", 0, () => { sub.select.value = ""; sub.select.dispatchEvent(new Event("change")); }); return el("div", { children: [ all, - el("div", { className: "m01-side__filter", children: [sub.root, detail.root] }), + el("div", { + className: "m01-side__filter", + children: [sub.root, ...(detail ? [detail.root] : [])], + }), ], }); }; @@ -176,69 +186,25 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const open = (file: FileInfo, sub: string, label: string, detail = ""): void => onOpen({ group, file, sub, detail, label }); const one = files[0]; - if (group === "인력" && one) { - body.replaceChildren(laborFilter(one, open)); - return; - } - if (group === "기계") { - const list = kinds.get(group) ?? []; - const all: TreeNode[] = one - ? [ - { - id: `${group}\n`, - label: L("M01_All"), - count: one.rows, - open: true, - run: () => open(one, "", L("M01_All")), - children: list.map((k) => ({ - id: `${group}\n${k.name}`, - label: k.name, - count: counts.get(`${group}\n${k.name}`) ?? null, - run: () => open(one, k.name, k.name), - children: k.details.map((d) => ({ - id: `${group}\n${k.name}\n${d}`, - label: d, - count: null, - run: () => open(one, k.name, `${k.name} · ${d}`, d), - })), - })), - }, - ] - : []; - body.replaceChildren(...renderTree(all, make)); - return; - } - if (group === "소요량" || group === "계수") { - // 전체 → 구분(원문+부문) → 상세구분(장) — 목록은 서버가 준 것 · 장 차례대로 - 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)); + const dropped: Partial> = { + 인력: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, + 기계: { label: L("M01_MachineSub") }, + 소요량: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, + 계수: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, + }; + const drop = dropped[group]; + if (drop && one) { + body.replaceChildren( + filter({ + id: `${group}|`, + store: `m01.filter.${group}`, + subs: kinds.get(group) ?? [], + total: files.reduce((n, f) => n + f.rows, 0), + subLabel: drop.label, + detailLabel: drop.detail, + onPick: (sub, detail, label) => open(one, sub, label, detail), + }), + ); return; } body.replaceChildren( @@ -263,14 +229,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const refresh = async (group: string): Promise => { const list = await fetchFiles(group); if (list[0] && (group === "인력" || group === "기계")) { - const file = list[0].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), - ), - ); + kinds.set(group, await fetchSubs({ file: list[0].file })); } else if (group === "소요량" || group === "계수") { kinds.set(group, await fetchSubs({ group })); } @@ -317,5 +276,5 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si void Promise.all(GROUPS.map(refresh)).catch((error) => showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), ); - return { root, logicHost, refresh, setActive }; + return { root, logicHost, refresh, setActive, filter }; } diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 99bcbfbd..1a518c44 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -213,31 +213,15 @@ font-size: var(--text-body-sm); } -.m01-master__tables { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: var(--spacing-8); -} - -.m01-master__table-card { - display: flex; - flex-direction: column; - gap: 2px; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text-body); - font: inherit; - text-align: left; +.m01-master__table-row { cursor: pointer; } -.m01-master__table-card:hover { +.m01-master__table-row:hover { background: var(--color-paper); } -.m01-master__table-card.is-changed { +.m01-master__table-row.is-changed { background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); } diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts index 9796e108..2e675a0a 100644 --- a/M01_MasterData/M01_MasterData_UI_Tables.ts +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -26,6 +26,7 @@ import { } from "./M01_MasterData_UI_Cells"; const LIST_SIZE = 30; +const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "비고"] as const; /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ export function renderTables(host: HTMLElement, pick: Pick, q: string): () => void { @@ -47,9 +48,9 @@ export function renderTables(host: HTMLElement, pick: Pick, q: string): () => vo /** 목록 위 「고침」 표시만 다시 — 목록을 다시 받지 않음. */ function markChanged(host: HTMLElement): void { - host.querySelectorAll(".m01-master__table-card").forEach((card) => { - const changed = peek(card.dataset.file!)?.tables ?? {}; - card.classList.toggle("is-changed", card.dataset.key! in changed); + host.querySelectorAll(".m01-master__table-row").forEach((row) => { + const changed = peek(row.dataset.file!)?.tables ?? {}; + row.classList.toggle("is-changed", row.dataset.key! in changed); }); } @@ -67,29 +68,34 @@ async function showList( showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); return; } - const cards = got.tables.map((t) => { - const card = el("button", { - className: "m01-master__table-card", - 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.원문번호} · ${t.기준 || "—"}`, - }), - el("span", { - className: "m01-master__muted", - text: `${Object.keys(t.조건 ?? {}).join(" · ") || "—"} → ${Object.keys(t.값칸 ?? {}).join(" · ")} · ${t.count} ${L("M01_Rows")}`, - }), - ], + const rows = got.tables.map((t) => { + const tr = el("tr", { + className: "m01-master__table-row", + attrs: { "data-key": t.키, "data-file": t.file }, + children: LIST_COLS.map((c) => + el("td", { text: String(t[c] ?? ""), attrs: { title: String(t[c] ?? "") } }), + ), }); - card.classList.toggle("is-changed", t.키 in (peek(t.file)?.tables ?? {})); - card.addEventListener("click", () => open(t.file, t.키)); - return card; + tr.classList.toggle("is-changed", t.키 in (peek(t.file)?.tables ?? {})); + tr.addEventListener("click", () => open(t.file, t.키)); + return tr; }); host.replaceChildren( buildPager(got.total, LIST_SIZE, page, (p) => void showList(host, pick, q, p, open)), - el("div", { className: "m01-master__tables", children: cards }), + el("div", { + className: "m01-master__grid-wrap m01-master__tables", + children: [ + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: LIST_COLS.map((c) => el("th", { text: c })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }), ); } diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index a8f3d6a9..480e0d46 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -304,8 +304,9 @@ def test_main_은_시스템관리자만() -> None: def test_고르기_자재품목_직종(client: TestClient) -> None: assert client.get("/api/m01/pick", params={"kind": "procure"}).status_code == 404 - market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=5) - assert market["items"] and market["items"][0]["관급"] and market["items"][0]["값"] > 0 + market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=50) + # 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨 + assert any(i["관급"] and i["값"] > 0 for i in market["items"]) job = _get(client, "/api/m01/pick", kind="job", q="보통인부") assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == key_of("LB:1002") assert _get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"] == key_of( diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 68fbbd6d..c00c325e 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -55,6 +55,7 @@ export const ui_locales_m1 = { M01_All: ["전체", "All"], M01_LaborSub: ["구분", "Category"], M01_LaborDetail: ["상세구분", "Detail"], + M01_MachineSub: ["세부분류", "Sub-category"], M01_GroupLabor: ["인력", "Labor"], M01_GroupMaterial: ["재료", "Materials"], M01_GroupMachine: ["기계", "Machines"],