From 3a36a0372caafc05b619427c966b511f35edba3a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 19 Sep 2026 18:45:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=A1=9C=EC=A7=81=20=EC=B9=B8=20=E2=80=94?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=ED=91=9C(=ED=98=B8?= =?UTF-8?q?=ED=91=9C)=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EA=B3=84=EC=82=B0=20=C2=B7=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면: 왼쪽 로직 목록(원문·장·이름 찾기·막힘) / 가운데 머리·받을 값·호표·중간 값·덧줄·끝수 / 오른쪽 시험 계산 - 고친 것은 캐시에 쌓고 [저장] 한 번에 · [고친 것 버리기] · 줄 더하기·지우기 · 로직 새로 만들기·지우기 - 진입은 mountM01Logic(host) — 공용 진입 파일(sub_laptop_3 몫)이 한 줄로 붙임 - 서버: 로직 하나에 단가 자동(prices) · 요소 찾기(GET /elements) · 저장 전 시험 계산(calc 에 row·file) - 계약 문서 갱신 · 시험 1개 더함(9개 통과) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- M01_MasterData/M01_MasterData_Router.py | 9 +- M01_MasterData/M01_MasterData_Store.py | 59 ++- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 164 +++++++ .../M01_MasterData_UI_Logic_Calc.ts | 164 +++++++ .../M01_MasterData_UI_Logic_Edit.ts | 423 ++++++++++++++++++ .../M01_MasterData_UI_Logic_List.ts | 155 +++++++ .../M01_MasterData_UI_Logic_Page.ts | 360 +++++++++++++++ .../M01_MasterData_UI_Logic_Pick.ts | 109 +++++ .../M01_MasterData_UI_Logic_Style.css | 388 ++++++++++++++++ .../M01_MasterData_UI_Logic_Text.ts | 99 ++++ resources/master_data/_화면_계약.md | 9 +- resources/tester/test_m01_api.py | 22 + 12 files changed, 1954 insertions(+), 7 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Api.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Calc.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Edit.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_List.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Page.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Pick.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Style.css create mode 100644 M01_MasterData/M01_MasterData_UI_Logic_Text.ts diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 17ffe0b3..0746de12 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -20,6 +20,8 @@ class CalcBody(BaseModel): book: str key: str inputs: dict[str, Any] = {} + row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로) + file: str | None = None # 새 로직이면 더할 로직 파일 class Change(BaseModel): @@ -91,9 +93,14 @@ def get_logic(book: str, key: str) -> dict: return _call(store.logic, book, key) +@router.get("/elements") +def get_elements(group: str, q: str = "", limit: int = 50) -> dict: + return _call(store.elements, group, q, limit) + + @router.post("/calc") def post_calc(body: CalcBody) -> dict: - return _call(store.calc, body.book, body.key, body.inputs) + return _call(store.calc, body.book, body.key, body.inputs, body.row, body.file) @router.post("/save") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 6540b563..9968fd86 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -173,7 +173,8 @@ def logic(book: str, key: str) -> dict: continue for row in data.get("줄") or []: if str(row.get("열쇠")) == key: - reasons = cm.mf.check_logic(cm.mf.Master(files), book, row)[0] + whole = cm.mf.Master(files) + reasons = cm.mf.check_logic(whole, book, row)[0] _, version = read(name) return { "file": name, @@ -181,15 +182,67 @@ def logic(book: str, key: str) -> dict: "logic": row, "blocked": bool(reasons), "reasons": reasons, + "prices": _prices(whole, row), } raise StoreError(404, f"없는 로직 「{book}:{key}」") +def _brief(ref: str, row: dict) -> dict: + return {"ref": ref, **{k: row.get(k) for k in ("이름", "규격", "단위", "값", "값칸")}} + + +def _prices(whole, row: dict) -> dict: + """호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐.""" + out = {} + for item in row.get("호표") or []: + ref = str(item.get("요소", "")) + if item.get("종류") != "로직" and "{" not in ref: + try: + out[ref] = _brief(ref, whole.get(ref)) + except cm.mf.FormulaError: + out[ref] = None + return out + + +def elements(group: str, q: str, limit: int) -> dict: + """요소 찾기 창 — 그룹 전체에서 이름·열쇠 찾기 · `ref` 는 식에 그대로 넣는 `그룹:원문:열쇠`.""" + if group not in cm.mf.GROUPS: + raise StoreError(404, f"없는 그룹 「{group}」") + hits = [] + for name, data in cm.load(folder=FOLDER).items(): + if data.get("그룹") != group: + continue + for row in data.get(items_key(data)) or []: + if _hit(row, q): + ref = f"{group}:{data.get('원문')}:{row.get('열쇠')}" + hits.append({**_brief(ref, row), "file": name}) + return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} + + # ── 시험 계산 ────────────────────────────────────────────────────────── -def calc(book: str, key: str, inputs: dict) -> dict: +def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None: + """저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함).""" + row = _dec(row) + for name, data in _logic_files(files): + if data.get("원문") == book: + items = data.get("줄") or [] + for i, old in enumerate(items): + if str(old.get("열쇠")) == key: + items[i] = row + return + if file not in files or files[file].get("그룹") != "로직": + raise StoreError(404, f"없는 로직 파일 「{file}」") + files[file].setdefault("줄", []).append(row) + + +def calc(book: str, key: str, inputs: dict, row: dict | None = None, file: str | None = None): given = {k: _dec(v) for k, v in inputs.items()} + files = cm.load(folder=FOLDER) + if row is not None: + _swap(files, book, key, row, file) + key = str(row.get("열쇠", key)) try: - result = cm.calc(cm.load(folder=FOLDER), f"{book}:{key}", given) + result = cm.calc(files, f"{book}:{key}", given) except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return {"ok": False, "reason": str(e) or type(e).__name__} if "결과" in result: diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts new file mode 100644 index 00000000..4f559904 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Api.ts + * 로직 화면이 부르는 서버 길 — 계약 `resources/master_data/_화면_계약.md` · `/api/m01` + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 로직 한 줄 — 파일 그대로(한글 칸) · 틀 `_틀.md` 6장 */ +export interface LogicInput { + 이름: string; + 단위?: string; + 고르기?: string[]; + 범위?: [number, number]; +} +export interface HoLine { + 종류: string; + 요소: string; + 이름?: string; + 규격?: string; + 단위?: string; + 수량: string; + 비목?: string; +} +export interface NamedFormula { + 이름: string; + 식: string; + 비목?: string; + 출처?: string; +} +export interface LogicRow { + 열쇠: string; + 이름: string; + 결과단위: string; + 출처: string; + 소유?: string; + 입력: LogicInput[]; + 중간: NamedFormula[]; + 호표?: HoLine[]; + 결과?: { 식: string }; + 덧줄?: NamedFormula[]; + 끝수?: string | null; + 비고?: string; + [extra: string]: unknown; +} + +export interface LogicSummary { + file: string; + book: string; + chapter: string; + 열쇠: string; + 이름: string; + 결과단위: string; + 출처: string; + blocked: boolean; + reasons: string[]; +} + +export interface ElementBrief { + ref: string; + file?: string; + 이름?: string; + 규격?: string; + 단위?: unknown; + 값?: unknown; + 값칸?: Record | null; +} + +export interface LogicOne { + file: string; + version: string; + logic: LogicRow; + blocked: boolean; + reasons: string[]; + prices: Record; +} + +export interface CalcLine { + 이름: string; + 단위: string; + 수량: number; + 단가: number; + 금액: number; + 비목: Record; +} +export type CalcAnswer = + | { + ok: true; + lines?: CalcLine[]; + sums?: Record; + result?: unknown; + middle: Record; + } + | { ok: false; reason: string }; + +export interface LogicFile { + file: string; + book: string; + chapter: string; + version: string; +} + +export interface SaveChange { + op: "edit" | "add" | "delete"; + key?: string; + row?: LogicRow; +} +export interface SaveFile { + file: string; + version: string; + changes: SaveChange[]; +} + +/** 서버가 준 몸과 상태 번호 — 409·422 는 몸(`detail`)을 화면이 풀어 보임 */ +export class ApiError extends Error { + constructor( + public status: number, + public detail: unknown, + ) { + super(typeof detail === "string" ? detail : JSON.stringify(detail)); + } +} + +async function request(path: string, body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}/m01${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +const query = (params: Record): string => new URLSearchParams(params).toString(); + +export const fetchLogics = (): Promise => + request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics); + +export const fetchLogic = (book: string, key: string): Promise => + request(`/logic?${query({ book, key })}`); + +export const fetchLogicFiles = (): Promise => + request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then( + (d) => d.files, + ); + +export const searchElements = ( + group: string, + q: string, +): Promise<{ total: number; items: ElementBrief[] }> => + request(`/elements?${query({ group, q, limit: "100" })}`); + +export const runCalc = (body: { + book: string; + key: string; + inputs: Record; + row?: LogicRow; + file?: string; +}): Promise => request("/calc", body); + +export const saveFiles = ( + files: SaveFile[], +): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files }); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts new file mode 100644 index 00000000..56bcb9b8 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Calc.ts + * 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로) + * 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { + runCalc, + type CalcAnswer, + type CalcLine, + type LogicRow, +} from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; + +export interface CalcContext { + book: string; + /** 저장된 열쇠 — 새 로직은 null */ + savedKey: string | null; + file: string; + row: LogicRow; + dirty: () => boolean; + /** 로직마다 넣은 값 — 다시 그려도 남음 */ + values: Record; + onLines: (lines: CalcLine[] | null) => void; +} + +const SUMS = ["노무비", "재료비", "경비", "계"]; + +export function buildCalc(host: HTMLElement, ctx: CalcContext): void { + const fields = (ctx.row.입력 ?? []).map((spec) => { + const name = spec.이름; + let control: HTMLInputElement | HTMLSelectElement; + if (spec.고르기?.length) { + control = el("select", { className: "m01-logic__input" }); + for (const option of ["", ...spec.고르기]) { + control.append(el("option", { text: option, attrs: { value: option } })); + } + } else { + control = el("input", { + className: "m01-logic__input", + attrs: { type: "text", inputmode: "decimal" }, + }); + if (spec.범위) control.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; + } + control.value = ctx.values[name] ?? ""; + control.addEventListener("input", () => (ctx.values[name] = control.value)); + control.addEventListener("change", () => (ctx.values[name] = control.value)); + const label = spec.단위 ? `${name} (${spec.단위})` : name; + return el("label", { + className: "m01-logic__field", + children: [el("span", { text: label }), control], + }); + }); + const out = el("div", { className: "m01-logic__calc-out" }); + const run = async (): Promise => { + const inputs: Record = {}; + for (const spec of ctx.row.입력 ?? []) { + const raw = (ctx.values[spec.이름] ?? "").trim(); + if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤 + inputs[spec.이름] = spec.고르기?.length || Number.isNaN(Number(raw)) ? raw : Number(raw); + } + const draft = ctx.dirty() || ctx.savedKey === null; + try { + const answer = await runCalc({ + book: ctx.book, + key: ctx.savedKey ?? ctx.row.열쇠, + inputs, + ...(draft ? { row: ctx.row, file: ctx.file } : {}), + }); + ctx.onLines(answer.ok ? (answer.lines ?? null) : null); + out.replaceChildren(...answerView(answer, draft)); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + host.replaceChildren( + el("h3", { text: tx("Calc_Title") }), + ...(fields.length + ? fields + : [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]), + createButton({ label: tx("Calc_Run"), onClick: () => void run() }), + out, + ); +} + +function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] { + const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : []; + if (!answer.ok) { + return [ + ...note, + el("div", { + className: "m01-logic__reasons", + children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })], + }), + ]; + } + const parts: HTMLElement[] = [...note]; + if (answer.lines) { + const rows = answer.lines.map((line) => + el("tr", { + children: [ + el("td", { text: line.이름 }), + el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }), + el("td", { className: "m01-logic__money", text: formatNumber(line.금액) }), + ], + }), + ); + parts.push( + el("table", { + className: "m01-logic__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tx("Head_Name"), tx("Ho_Qty"), tx("Ho_Amount")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ); + } + if (answer.sums) { + parts.push( + el("dl", { + className: "m01-logic__sums", + children: SUMS.flatMap((k) => [ + el("dt", { text: k === "계" ? tx("Calc_Sum") : k }), + el("dd", { className: "m01-logic__money", text: formatNumber(answer.sums?.[k] ?? 0) }), + ]), + }), + ); + } + if (answer.result !== undefined) { + parts.push( + el("dl", { + className: "m01-logic__sums", + children: [ + el("dt", { text: tx("Calc_Result") }), + el("dd", { text: formatNumber(answer.result) }), + ], + }), + ); + } + const middle = Object.entries(answer.middle ?? {}); + if (middle.length) { + parts.push( + el("h4", { text: tx("Middle_Title") }), + el("dl", { + className: "m01-logic__sums", + children: middle.flatMap(([k, v]) => [ + el("dt", { text: k }), + el("dd", { text: formatNumber(v) }), + ]), + }), + ); + } + return parts; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts new file mode 100644 index 00000000..38fd879f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts @@ -0,0 +1,423 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Edit.ts + * 로직 한 줄 고치기 — 실무 일위대가표(호표) 모양 + * 머리(이름·결과단위·출처·소유) · 설계에서 받을 값 · 호표 줄 · 중간 값 · 결과 식 · 덧줄 · 끝수 + * + * 칸을 고치면 `row` 를 그 자리에서 바꾸고 `onChange` 만 부름(다시 그리지 않음). + * 줄 더하기·지우기 · 요소 고르기처럼 모양이 바뀔 때만 `rerender`. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { + CalcLine, + ElementBrief, + HoLine, + LogicInput, + LogicRow, + NamedFormula, +} from "./M01_MasterData_UI_Logic_Api"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; + +export type PickMode = "element" | "table"; +export type PickDone = (item: ElementBrief, column?: string) => void; + +export interface EditContext { + row: LogicRow; + file: string; + isNew: boolean; + files: string[]; + reasons: string[]; + prices: Record; + /** 마지막 시험 계산의 줄 — 호표 차례와 같음(덧줄은 뒤에 붙음) */ + lines: CalcLine[] | null; + onChange: () => void; + onFile: (file: string) => void; + onPick: (mode: PickMode, group: string, done: PickDone) => void; +} + +const KINDS = ["인력", "재료", "기계", "로직"]; +const COSTS = ["노무비", "재료비", "경비"]; +const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; + +export function formatNumber(value: unknown): string { + if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); + if (value === null || value === undefined) return ""; + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + +function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement { + const input = el("input", { + className: `m01-logic__input ${className}`, + attrs: { type: "text" }, + }); + input.value = value === null || value === undefined ? "" : String(value); + input.addEventListener("input", () => onInput(input.value)); + return input; +} + +function choice( + options: string[], + value: string | undefined, + onPick: (v: string) => void, +): HTMLSelectElement { + const select = el("select", { className: "m01-logic__input" }); + for (const option of value && !options.includes(value) ? [value, ...options] : options) { + select.append(el("option", { text: option, attrs: { value: option } })); + } + select.value = value ?? options[0]; + select.addEventListener("change", () => onPick(select.value)); + return select; +} + +function dropButton(onClick: () => void): HTMLButtonElement { + const button = el("button", { + className: "m01-logic__drop", + text: "×", + attrs: { type: "button", title: tx("DeleteRow") }, + }); + button.addEventListener("click", onClick); + return button; +} + +function section(title: string, body: HTMLElement, onAdd?: () => void): HTMLElement { + const head = el("div", { + className: "m01-logic__section-head", + children: [el("h3", { text: title })], + }); + if (onAdd) head.append(createButton({ label: tx("AddRow"), variant: "ghost", onClick: onAdd })); + return el("section", { className: "m01-logic__section", children: [head, body] }); +} + +function grid(headers: string[], rows: HTMLElement[][], className: string): HTMLElement { + const table = el("table", { className: `m01-logic__grid ${className}` }); + table.append( + el("thead", { children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })] }), + ); + const body = el("tbody"); + for (const cells of rows) + body.append(el("tr", { children: cells.map((c) => el("td", { children: [c] })) })); + table.append(body); + return el("div", { className: "m01-logic__scroll", children: [table] }); +} + +export function qtyKind(qty: string): string { + const text = qty.trim(); + if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber"); + if (text.startsWith("찾기(")) return tx("Ho_QtyTable"); + return tx("Ho_QtyFormula"); +} + +export function buildEditor(host: HTMLElement, ctx: EditContext): void { + const rerender = (): void => { + ctx.onChange(); + buildEditor(host, ctx); + }; + const row = ctx.row; + const set = (patch: () => void): void => { + patch(); + ctx.onChange(); + }; + host.innerHTML = ""; + host.append(head(ctx, set, rerender)); + if (ctx.reasons.length) { + host.append( + el("div", { + className: "m01-logic__reasons", + children: [ + el("strong", { text: tx("Head_Blocked") }), + ...ctx.reasons.map((r) => el("div", { text: r })), + ], + }), + ); + } + host.append(inputsSection(row, set, rerender)); + const money = !("결과" in row) && (row.결과단위 ?? "").startsWith("원"); + if (money) host.append(hoSection(ctx, set, rerender)); + host.append(middleSection(row, set, rerender)); + if (!money) { + const formula = textBox(row.결과?.식 ?? "", (v) => + set(() => { + row.결과 = { 식: v }; + }), + ); + host.append(section(tx("Result_Title"), formula)); + } else { + host.append(extraSection(row, set, rerender)); + const rounding = textBox(row.끝수 ?? "", (v) => + set(() => { + row.끝수 = v.trim() ? v : null; + }), + ); + host.append(section(tx("Rounding"), rounding)); + } +} + +function field(label: string, control: HTMLElement, wide = false): HTMLElement { + return el("label", { + className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`, + children: [el("span", { text: label }), control], + }); +} + +function head(ctx: EditContext, set: (p: () => void) => void, rerender: () => void): HTMLElement { + const row = ctx.row; + const file = ctx.isNew + ? choice(ctx.files, ctx.file, (v) => ctx.onFile(v)) + : el("span", { className: "m01-logic__file", text: ctx.file }); + const unit = textBox(row.결과단위, (v) => set(() => (row.결과단위 = v))); + unit.addEventListener("change", rerender); // 돈 로직 ↔ 돈 아닌 로직 칸이 바뀜 + const note = el("textarea", { className: "m01-logic__input m01-logic__note" }); + note.value = row.비고 ?? ""; + note.addEventListener("input", () => + set(() => { + if (note.value.trim()) row.비고 = note.value; + else delete row.비고; + }), + ); + return el("div", { + className: "m01-logic__head", + children: [ + field(tx("Head_File"), file, true), + field( + tx("Head_Key"), + textBox(row.열쇠, (v) => set(() => (row.열쇠 = v))), + ), + field( + tx("Head_Name"), + textBox(row.이름, (v) => set(() => (row.이름 = v))), + ), + field(tx("Head_Unit"), unit), + field( + tx("Head_Source"), + textBox(row.출처, (v) => set(() => (row.출처 = v))), + ), + field( + tx("Head_Owner"), + choice(["공용", "개인"], row.소유, (v) => set(() => (row.소유 = v))), + ), + field(tx("Head_Note"), note, true), + ], + }); +} + +function inputsSection( + row: LogicRow, + set: (p: () => void) => void, + rerender: () => void, +): HTMLElement { + const list = row.입력 ?? []; + const rows = list.map((spec: LogicInput, i) => { + const low = textBox(spec.범위?.[0] ?? "", () => range(), "m01-logic__num"); + const high = textBox(spec.범위?.[1] ?? "", () => range(), "m01-logic__num"); + const range = (): void => + set(() => { + if (low.value.trim() === "" && high.value.trim() === "") delete spec.범위; + else spec.범위 = [Number(low.value), Number(high.value)]; + }); + return [ + textBox(spec.이름, (v) => set(() => (spec.이름 = v))), + textBox(spec.단위 ?? "", (v) => + set(() => { + if (v.trim()) spec.단위 = v; + else delete spec.단위; + }), + ), + textBox((spec.고르기 ?? []).join(", "), (v) => + set(() => { + const items = v + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + if (items.length) spec.고르기 = items; + else delete spec.고르기; + }), + ), + el("div", { + className: "m01-logic__range", + children: [low, el("span", { text: "∼" }), high], + }), + dropButton(() => { + list.splice(i, 1); + rerender(); + }), + ]; + }); + const headers = [ + tx("Head_Name"), + tx("Inputs_Unit"), + tx("Inputs_Choices"), + tx("Inputs_Range"), + "", + ]; + return section(tx("Inputs_Title"), grid(headers, rows, "m01-logic__grid--inputs"), () => { + (row.입력 ??= []).push({ 이름: "" }); + rerender(); + }); +} + +function priceCell(ctx: EditContext, item: HoLine, i: number): HTMLElement { + const line = ctx.lines?.[i]; + if (line) return el("span", { className: "m01-logic__money", text: formatNumber(line.단가) }); + if (item.종류 === "로직" || item.요소.includes("{")) { + return el("span", { className: "m01-logic__muted", text: tx("Ho_PriceLater") }); + } + const brief = ctx.prices[item.요소]; + if (brief === undefined) return el("span", { className: "m01-logic__muted", text: "" }); + if (brief === null) return el("span", { className: "m01-logic__bad", text: tx("Ho_Missing") }); + return el("span", { className: "m01-logic__money", text: formatNumber(brief.값) }); +} + +function hoSection( + ctx: EditContext, + set: (p: () => void) => void, + rerender: () => void, +): HTMLElement { + const list = ctx.row.호표 ?? []; + const rows = list.map((item: HoLine, i) => { + const element = textBox(item.요소, (v) => set(() => (item.요소 = v)), "m01-logic__ref"); + const pick = createButton({ + label: tx("Ho_Pick"), + variant: "ghost", + onClick: () => + ctx.onPick("element", item.종류, (found) => { + const [group, book] = found.ref.split(":"); + const key = found.ref.slice(group.length + book.length + 2); + item.요소 = group === "로직" ? `로직(${book}:${key})` : found.ref; + if (KINDS.includes(group)) item.종류 = group; + item.이름 = found.이름 ?? item.이름; + if (found.규격) item.규격 = found.규격; + else delete item.규격; + if (!item.비목 && COST_OF[item.종류]) item.비목 = COST_OF[item.종류]; + if (group !== "로직") ctx.prices[found.ref] = found; + rerender(); + }), + }); + const brief = ctx.prices[item.요소]; + const spec = item.규격 ?? brief?.규격 ?? ""; + const qty = textBox(item.수량, (v) => { + set(() => (item.수량 = v)); + tag.textContent = qtyKind(v); + }); + const tag = el("span", { className: "m01-logic__tag", text: qtyKind(item.수량) }); + const table = createButton({ + label: tx("Ho_PickTable"), + variant: "ghost", + onClick: () => + ctx.onPick("table", "소요량", (found, column) => { + const ref = found.ref; + item.수량 = `찾기(${ref})${column ? `.${column}` : ""}`; + rerender(); + }), + }); + const cost = + item.종류 === "로직" && !item.비목 + ? choice(["", ...COSTS], "", (v) => set(() => (item.비목 = v || undefined))) + : choice(COSTS, item.비목, (v) => set(() => (item.비목 = v))); + return [ + choice(KINDS, item.종류, (v) => { + item.종류 = v; + rerender(); + }), + el("div", { className: "m01-logic__pair", children: [element, pick] }), + el("div", { + className: "m01-logic__stack", + children: [ + textBox(item.이름 ?? "", (v) => set(() => (item.이름 = v))), + el("span", { className: "m01-logic__muted", text: spec }), + ], + }), + textBox(item.단위 ?? "", (v) => set(() => (item.단위 = v)), "m01-logic__unit"), + el("div", { className: "m01-logic__pair", children: [tag, qty, table] }), + priceCell(ctx, item, i), + el("span", { className: "m01-logic__money", text: formatNumber(ctx.lines?.[i]?.금액 ?? "") }), + cost, + dropButton(() => { + list.splice(i, 1); + rerender(); + }), + ]; + }); + const headers = [ + tx("Ho_Kind"), + tx("Ho_Element"), + tx("Ho_Name"), + tx("Ho_Unit"), + tx("Ho_Qty"), + tx("Ho_Price"), + tx("Ho_Amount"), + tx("Ho_Cost"), + "", + ]; + return section(tx("Ho_Title"), grid(headers, rows, "m01-logic__grid--ho"), () => { + (ctx.row.호표 ??= []).push({ + 종류: "인력", + 요소: "", + 이름: "", + 단위: "인", + 수량: "", + 비목: "노무비", + }); + rerender(); + }); +} + +function formulaRows( + list: NamedFormula[], + set: (p: () => void) => void, + rerender: () => void, + withCost: boolean, +): HTMLElement[][] { + return list.map((item, i) => { + const cells: HTMLElement[] = [ + textBox(item.이름, (v) => set(() => (item.이름 = v))), + textBox(item.식, (v) => set(() => (item.식 = v)), "m01-logic__formula"), + ]; + if (withCost) { + cells.push( + choice(COSTS, item.비목, (v) => set(() => (item.비목 = v))), + textBox(item.출처 ?? "", (v) => set(() => (item.출처 = v))), + ); + } + cells.push( + dropButton(() => { + list.splice(i, 1); + rerender(); + }), + ); + return cells; + }); +} + +function middleSection( + row: LogicRow, + set: (p: () => void) => void, + rerender: () => void, +): HTMLElement { + const list = row.중간 ?? []; + const table = grid( + [tx("Head_Name"), tx("Formula"), ""], + formulaRows(list, set, rerender, false), + "", + ); + return section(tx("Middle_Title"), table, () => { + (row.중간 ??= []).push({ 이름: "", 식: "" }); + rerender(); + }); +} + +function extraSection( + row: LogicRow, + set: (p: () => void) => void, + rerender: () => void, +): HTMLElement { + const list = row.덧줄 ?? []; + const headers = [tx("Head_Name"), tx("Formula"), tx("Ho_Cost"), tx("Head_Source"), ""]; + return section( + tx("Extra_Title"), + grid(headers, formulaRows(list, set, rerender, true), ""), + () => { + (row.덧줄 ??= []).push({ 이름: "", 식: "", 비목: "재료비", 출처: "" }); + rerender(); + }, + ); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts new file mode 100644 index 00000000..738436e0 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_List.ts + * 왼쪽 로직 목록 — 원문 · 장 · 이름 찾기 · 막힘만 · 막힘(⛔)·고침(●) 표시 + * 거르기는 화면에서(목록은 한 번 받음 · 391 줄 남짓) + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; + +export type ListMark = "edited" | "new" | "deleted"; + +export interface ListItem { + id: string; + book: string; + chapter: string; + key: string; + name: string; + blocked: boolean; + reasons: string[]; +} + +export interface ListHandle { + root: HTMLElement; + setItems: (items: LogicSummary[]) => void; + /** 저장 안 한 새 로직도 목록 맨 위에 */ + setMarks: (marks: Map, extra: ListItem[]) => void; + setActive: (id: string | null) => void; +} + +export const logicId = (book: string, key: string): string => `${book}\n${key}`; + +export function buildList(onOpen: (item: ListItem) => void): ListHandle { + let items: ListItem[] = []; + let extra: ListItem[] = []; + let marks = new Map(); + let active: string | null = null; + const book = el("select", { className: "m01-logic__input" }); + const chapter = el("select", { className: "m01-logic__input" }); + const search = el("input", { + className: "m01-logic__input", + attrs: { type: "search", placeholder: tx("List_Search") }, + }); + const blockedOnly = el("input", { attrs: { type: "checkbox" } }); + const count = el("span", { className: "m01-logic__muted" }); + const list = el("ul", { className: "m01-logic__list" }); + + const options = (select: HTMLSelectElement, all: string, values: string[]): void => { + const keep = select.value; + select.replaceChildren( + el("option", { text: all, attrs: { value: "" } }), + ...values.map((v) => el("option", { text: v, attrs: { value: v } })), + ); + select.value = values.includes(keep) ? keep : ""; + }; + const chapters = (): void => + options(chapter, tx("List_AllChapters"), [ + ...new Set(items.filter((x) => !book.value || x.book === book.value).map((x) => x.chapter)), + ]); + + const draw = (): void => { + const q = search.value.trim().toLowerCase(); + const shown = [...extra, ...items].filter( + (x) => + (!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)), + ); + count.textContent = tx("List_Count", { n: shown.length }); + list.replaceChildren(...shown.slice(0, 500).map(line)); + }; + 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 }, + children: [ + el("span", { className: "m01-logic__muted", text: `${item.book} ${item.chapter}` }), + el("span", { text: item.name || item.key }), + el("span", { className: "m01-logic__badges", children: badges }), + ], + }); + button.addEventListener("click", () => onOpen(item)); + return el("li", { children: [button] }); + }; + + book.addEventListener("change", () => { + chapters(); + draw(); + }); + chapter.addEventListener("change", draw); + search.addEventListener("input", draw); + blockedOnly.addEventListener("change", draw); + + const root = el("aside", { + className: "m01-logic__side", + children: [ + el("div", { + className: "m01-logic__filters", + children: [ + book, + chapter, + search, + el("label", { + className: "m01-logic__check", + children: [blockedOnly, el("span", { text: tx("List_BlockedOnly") })], + }), + count, + ], + }), + list, + ], + }); + return { + root, + setItems: (logics) => { + items = logics.map((x) => ({ + id: logicId(x.book, x.열쇠), + book: x.book, + chapter: x.chapter, + key: x.열쇠, + name: x.이름, + blocked: x.blocked, + reasons: x.reasons, + })); + options(book, tx("List_AllBooks"), [...new Set(items.map((x) => x.book))]); + chapters(); + draw(); + }, + setMarks: (next, more) => { + marks = next; + extra = more; + draw(); + }, + setActive: (id) => { + active = id; + draw(); + }, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts new file mode 100644 index 00000000..78ba6bc9 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -0,0 +1,360 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Page.ts + * 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 + * 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫) + * + * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. + * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. + * ========================================================================== */ + +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { + ApiError, + fetchLogic, + fetchLogicFiles, + fetchLogics, + saveFiles, + type CalcLine, + type ElementBrief, + type LogicFile, + type LogicRow, + type SaveFile, +} from "./M01_MasterData_UI_Logic_Api"; +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 { tx } from "./M01_MasterData_UI_Logic_Text"; +import "./M01_MasterData_UI_Logic_Style.css"; + +/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ +interface Draft { + book: string; + file: string; + version: string; + origKey: string | null; + row: LogicRow | null; +} + +interface Opened extends Draft { + id: string; + row: LogicRow | null; + original: string | null; + prices: Record; + reasons: string[]; + lines: CalcLine[] | null; + values: Record; +} + +const CACHE_KEY = "m01_logic_drafts"; +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +function loadDrafts(): Record { + try { + return JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "{}") as Record; + } catch { + return {}; + } +} + +export async function mountM01Logic(host: HTMLElement): Promise { + let drafts = loadDrafts(); + let files: LogicFile[] = []; + let opened: Opened | null = null; + let calcInputs = ""; + + 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 pending = el("span", { className: "m01-logic__muted" }); + const list = buildList((item) => void open(item.id, item.book, item.key)); + + const persist = (): void => { + try { + sessionStorage.setItem(CACHE_KEY, JSON.stringify(drafts)); + } catch { + /* 캐시가 막혀도 화면 안의 고친 것은 남음 */ + } + const marks = new Map(); + const extra: ListItem[] = []; + for (const [id, d] of Object.entries(drafts)) { + marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited"); + if (d.origKey === null && d.row) { + extra.push({ + id, + book: d.book, + chapter: "", + key: d.row.열쇠, + name: d.row.이름, + blocked: false, + reasons: [], + }); + } + } + list.setMarks(marks, extra); + const n = Object.keys(drafts).length; + pending.textContent = n ? tx("Bar_Pending", { n }) : ""; + saveButton.disabled = discardButton.disabled = n === 0; + }; + + const touch = (): void => { + if (!opened?.row) return; + const now = JSON.stringify(opened.row); + if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; + else drafts[opened.id] = pick(opened, clone(opened.row)); + persist(); + const inputs = JSON.stringify(opened.row.입력 ?? []); + if (inputs !== calcInputs) drawCalc(); + }; + + const pick = (o: Opened, row: LogicRow | null): Draft => ({ + book: o.book, + file: o.file, + version: o.version, + origKey: o.origKey, + row, + }); + + const drawCalc = (): void => { + if (!opened?.row) { + calc.replaceChildren(); + return; + } + const current = opened; + calcInputs = JSON.stringify(current.row?.입력 ?? []); + buildCalc(calc, { + book: current.book, + savedKey: current.origKey, + file: current.file, + row: current.row as LogicRow, + dirty: () => current.id in drafts, + values: current.values, + onLines: (lines) => { + current.lines = lines; + drawEditor(); + }, + }); + }; + + const drawEditor = (): void => { + list.setActive(opened?.id ?? null); + if (!opened) { + editor.replaceChildren(el("p", { className: "m01-logic__empty", text: tx("Empty") })); + return; + } + if (!opened.row) { + editor.replaceChildren( + el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }), + ); + return; + } + const current = opened; + buildEditor(editor, { + row: current.row as LogicRow, + file: current.file, + isNew: current.origKey === null, + files: files.map((f) => f.file), + reasons: current.reasons, + prices: current.prices, + lines: current.lines, + onChange: touch, + onFile: (file) => { + const f = files.find((x) => x.file === file); + if (!f) return; + Object.assign(current, { file: f.file, book: f.book, version: f.version }); + touch(); + }, + onPick: openPicker, + }); + }; + + const show = (next: Opened | null): void => { + opened = next; + errors.hidden = true; + drawEditor(); + drawCalc(); + }; + + const open = async (id: string, book: string, key: string): Promise => { + const draft = drafts[id]; + if (draft?.origKey === null) { + show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} }); + return; + } + try { + const one = await fetchLogic(book, key); + show({ + id, + book, + file: draft?.file ?? one.file, + version: draft?.version ?? one.version, + origKey: key, + row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic), + original: JSON.stringify(one.logic), + prices: one.prices, + reasons: one.reasons, + lines: null, + values: {}, + }); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + + const reload = async (): Promise => { + const [logics, logicFiles] = await Promise.all([fetchLogics(), fetchLogicFiles()]); + files = logicFiles; + list.setItems(logics); + persist(); + }; + + const onNew = (): void => { + const f = files.find((x) => x.file === opened?.file) ?? files[0]; + if (!f) return; + const id = `new:${Date.now()}`; + const row: LogicRow = { + 열쇠: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`, + 이름: "", + 결과단위: "원/", + 출처: "", + 소유: "공용", + 입력: [], + 중간: [], + 호표: [], + 덧줄: [], + 끝수: null, + }; + const next: Opened = { + id, + book: f.book, + file: f.file, + version: f.version, + origKey: null, + row, + original: null, + prices: {}, + reasons: [], + lines: null, + values: {}, + }; + drafts[id] = pick(next, clone(row)); + persist(); + show(next); + }; + + const onDelete = async (): Promise => { + if (!opened?.row) return; + if (!(await showConfirmDialog(tx("Confirm_Delete", { v: opened.row.열쇠 }), tx("Bar_Delete")))) + return; + if (opened.origKey === null) { + delete drafts[opened.id]; + persist(); + show(null); + return; + } + drafts[opened.id] = pick(opened, null); + opened.row = null; + persist(); + show(opened); + }; + + const onDiscard = async (): Promise => { + if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return; + drafts = {}; + persist(); + const was = opened; + if (was && was.origKey !== null) + await open(logicId(was.book, was.origKey), was.book, was.origKey); + else show(null); + }; + + const onSave = async (): Promise => { + const byFile = new Map(); + for (const d of Object.values(drafts)) { + const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] }; + if (d.origKey === null) { + if (d.row) part.changes.push({ op: "add", row: d.row }); + } else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey }); + else part.changes.push({ op: "edit", key: d.origKey, row: d.row }); + byFile.set(d.file, part); + } + if (!byFile.size) { + showToast(tx("Save_Nothing"), "info"); + return; + } + showLoadingOverlay(); + try { + await saveFiles([...byFile.values()]); + const was = opened; + drafts = {}; + await reload(); + showToast(tx("Save_Done"), "success"); + if (was?.row) await open(logicId(was.book, was.row.열쇠), was.book, was.row.열쇠); + else show(null); + } catch (error) { + failed(error); + } finally { + hideLoadingOverlay(); + } + }; + + const failed = (error: unknown): void => { + const detail = + error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null; + if (error instanceof ApiError && error.status === 409) { + showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error"); + } else if (error instanceof ApiError && error.status === 422) { + errors.replaceChildren( + el("strong", { text: tx("Save_Errors") }), + ...(detail?.errors ?? []).map((x) => el("div", { text: x })), + ); + errors.hidden = false; + } else { + showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error"); + } + }; + + const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); + const discardButton = createButton({ + label: tx("Bar_Discard"), + variant: "ghost", + onClick: () => void onDiscard(), + }); + const bar = el("div", { + className: "m01-logic__bar", + children: [ + el("h2", { text: tx("Title") }), + pending, + createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }), + createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), + discardButton, + saveButton, + ], + }); + host.replaceChildren( + el("div", { + className: "m01-logic", + children: [ + list.root, + el("main", { className: "m01-logic__main", children: [bar, errors, editor] }), + calc, + ], + }), + ); + show(null); + showLoadingOverlay(); + try { + await reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts new file mode 100644 index 00000000..a49d6f7e --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Pick.ts @@ -0,0 +1,109 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Pick.ts + * 요소 찾기 창 — 그룹 전체에서 이름·열쇠로 찾아 호표 줄에 넣음(`GET /elements`) + * 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { searchElements, type ElementBrief } from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit"; +import { tx } from "./M01_MasterData_UI_Logic_Text"; + +const GROUPS: Record = { + element: ["인력", "재료", "기계", "로직"], + table: ["소요량", "계수"], +}; + +export function openPicker(mode: PickMode, group: string, done: PickDone): void { + const groups = GROUPS[mode]; + const select = el("select", { className: "m01-logic__input" }); + for (const g of groups) select.append(el("option", { text: g, attrs: { value: g } })); + select.value = groups.includes(group) ? group : groups[0]; + const search = el("input", { + className: "m01-logic__input", + attrs: { type: "search", placeholder: tx("Pick_Search") }, + }); + const count = el("span", { className: "m01-logic__muted" }); + const list = el("div", { className: "m01-logic__pick-list" }); + const close = (): void => backdrop.remove(); + const dialog = el("div", { + className: "m01-logic__pick", + attrs: { role: "dialog", "aria-label": tx("Pick_Title") }, + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h3", { text: tx("Pick_Title") }), + createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }), + ], + }), + el("div", { className: "m01-logic__pick-bar", children: [select, search, count] }), + list, + ], + }); + const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); + backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); + backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); + + const choose = (item: ElementBrief, column?: string): void => { + close(); + done(item, column); + }; + let seq = 0; + const load = async (): Promise => { + const mine = ++seq; + try { + const found = await searchElements(select.value, search.value.trim()); + if (mine !== seq) return; // 최신 응답만 + count.textContent = tx("Pick_Total", { n: found.total }); + list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose))); + } catch (error) { + showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); + } + }; + let timer = 0; + search.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(load, 250); + }); + select.addEventListener("change", load); + document.body.append(backdrop); + search.focus(); + void load(); +} + +function itemRow( + item: ElementBrief, + mode: PickMode, + choose: (item: ElementBrief, column?: string) => void, +): HTMLElement { + const text = el("div", { + className: "m01-logic__stack", + children: [ + el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }), + el("span", { className: "m01-logic__muted", text: item.ref }), + ], + }); + if (mode === "table") { + const columns = Object.keys(item.값칸 ?? {}); + const chips = columns.map((column) => + createButton({ label: column, variant: "pill", onClick: () => choose(item, column) }), + ); + return el("div", { + className: "m01-logic__pick-row", + children: [text, el("div", { className: "m01-logic__chips", children: chips })], + }); + } + const unit = typeof item.단위 === "string" ? item.단위 : ""; + const value = typeof item.값 === "object" && item.값 !== null ? "{…}" : formatNumber(item.값); + const row = el("button", { + className: "m01-logic__pick-row m01-logic__pick-row--button", + attrs: { type: "button" }, + children: [ + text, + el("span", { className: "m01-logic__money", text: `${value} ${unit}`.trim() }), + ], + }); + row.addEventListener("click", () => choose(item)); + return row; +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css new file mode 100644 index 00000000..0bbf5961 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -0,0 +1,388 @@ +/* M01 로직 칸 — 왼쪽 목록 / 가운데 일위대가표 / 오른쪽 시험 계산 */ +.m01-logic [hidden] { + display: none !important; +} + +.m01-logic { + display: grid; + grid-template-columns: 240px minmax(0, 1fr) 280px; + gap: var(--spacing-12); + height: 100%; + min-height: 0; + padding: var(--spacing-12); + box-sizing: border-box; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-logic__side, +.m01-logic__main, +.m01-logic__calc { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; + overflow: auto; +} + +.m01-logic__side, +.m01-logic__calc { + padding: var(--spacing-8); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + +.m01-logic__filters { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-logic__check { + display: flex; + align-items: center; + 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; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +.m01-logic__bar h2 { + margin: 0 auto 0 0; + font-size: 18px; + color: var(--color-text); +} + +.m01-logic__empty { + color: var(--color-text-muted); +} + +.m01-logic__head { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--spacing-8); +} + +.m01-logic__field { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m01-logic__field--wide { + grid-column: 1 / -1; +} + +.m01-logic__file { + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-logic__input { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-logic__note { + min-height: 48px; + resize: vertical; +} + +.m01-logic__section { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-logic__section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m01-logic__section-head h3, +.m01-logic__calc h3 { + margin: 0; + font-size: var(--text-body-sm); + color: var(--color-text); +} + +.m01-logic__grid { + width: 100%; + border-collapse: collapse; +} + +.m01-logic__grid th, +.m01-logic__grid td { + padding: 2px 4px; + border-bottom: 1px solid var(--color-border); + vertical-align: top; + text-align: left; +} + +.m01-logic__grid th { + color: var(--color-text-secondary); + font-size: var(--text-caption); + font-weight: 600; +} + +.m01-logic__grid th, +.m01-logic__grid .ui-btn { + white-space: nowrap; +} + +.m01-logic__scroll { + overflow-x: auto; +} + +.m01-logic__grid--ho { + min-width: 1124px; + table-layout: fixed; +} + +.m01-logic__grid--ho th:nth-child(1) { + width: 76px; +} + +.m01-logic__grid--ho th:nth-child(2) { + width: 220px; +} + +.m01-logic__grid--ho th:nth-child(3) { + width: 150px; +} + +.m01-logic__grid--ho th:nth-child(4) { + width: 56px; +} + +.m01-logic__grid--ho th:nth-child(5) { + width: 300px; +} + +.m01-logic__grid--ho th:nth-child(6), +.m01-logic__grid--ho th:nth-child(7) { + width: 100px; +} + +.m01-logic__grid--ho th:nth-child(8) { + width: 90px; +} + +.m01-logic__grid--ho th:nth-child(9) { + width: 32px; +} + +.m01-logic__pair { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.m01-logic__stack { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.m01-logic__range { + display: flex; + align-items: center; + gap: 2px; +} + +.m01-logic__num { + width: 64px; +} + +.m01-logic__tag { + flex: none; + padding: 0 6px; + border-radius: var(--radius-pills); + background: var(--color-mist-violet); + color: var(--color-accent); + font-size: var(--text-caption); +} + +.m01-logic__money { + display: block; + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.m01-logic__muted { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-logic__bad { + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-logic__drop { + border: 0; + background: none; + color: var(--color-text-muted); + font-size: 16px; + cursor: pointer; +} + +.m01-logic__drop:hover { + color: var(--color-danger); +} + +.m01-logic__reasons { + padding: var(--spacing-8); + border-left: 3px solid var(--color-danger); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-danger); +} + +.m01-logic__sums { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px var(--spacing-8); + margin: 0; +} + +.m01-logic__sums dt { + color: var(--color-text-secondary); +} + +.m01-logic__sums dd { + margin: 0; + text-align: right; +} + +.m01-logic__calc-out { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01-logic__backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--color-ink) 30%, transparent); +} + +.m01-logic__pick { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + width: min(720px, 92vw); + max-height: 80vh; + padding: var(--spacing-16); + border-radius: var(--radius-xl); + background: var(--color-surface-raised); + font-size: var(--text-body-sm); +} + +.m01-logic__pick-bar { + display: grid; + grid-template-columns: 120px 1fr auto; + align-items: center; + gap: var(--spacing-8); +} + +.m01-logic__pick-list { + overflow: auto; +} + +.m01-logic__pick-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-bottom: 1px solid var(--color-border); + background: none; + color: inherit; + font: inherit; + text-align: left; +} + +.m01-logic__pick-row--button { + cursor: pointer; +} + +.m01-logic__pick-row--button:hover { + background: var(--color-paper); +} + +.m01-logic__chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts new file mode 100644 index 00000000..05020d41 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -0,0 +1,99 @@ +/* ============================================================================= + * M01_MasterData_UI_Logic_Text.ts + * 로직 화면 글자 — [한국어, 영어] · 공용 사전과 같은 모양 · 창끼리 공용 사전 줄이 겹치지 않게 따로 둠 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Title: ["일위대가 로직", "Unit-cost logic"], + List_Search: ["이름 찾기", "Search name"], + List_AllBooks: ["원문 모두", "All sources"], + List_AllChapters: ["장 모두", "All chapters"], + List_BlockedOnly: ["막힘만", "Blocked only"], + List_Count: ["{n} 개", "{n} items"], + List_Blocked: ["막힘", "Blocked"], + List_Edited: ["고침", "Edited"], + List_New: ["새것", "New"], + List_Deleted: ["지움", "Deleted"], + 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_Name: ["이름", "Name"], + Head_Unit: ["결과단위", "Result unit"], + Head_Source: ["출처", "Source"], + Head_Owner: ["소유", "Owner"], + Head_Note: ["비고", "Note"], + Head_Blocked: ["막힌 까닭", "Why blocked"], + Inputs_Title: ["설계에서 받을 값", "Values from design"], + Inputs_Unit: ["단위", "Unit"], + Inputs_Choices: ["고르기(쉼표로)", "Choices (comma)"], + Inputs_Range: ["범위", "Range"], + Ho_Title: ["호표", "Unit-cost lines"], + Ho_Kind: ["종류", "Kind"], + Ho_Element: ["요소", "Element"], + Ho_Name: ["이름·규격", "Name · spec"], + Ho_Unit: ["단위", "Unit"], + Ho_Qty: ["수량", "Qty"], + Ho_Price: ["단가", "Unit price"], + Ho_Amount: ["금액", "Amount"], + Ho_Cost: ["비목", "Cost item"], + Ho_Pick: ["찾기", "Find"], + Ho_PickTable: ["표", "Table"], + Ho_QtyNumber: ["수", "Number"], + Ho_QtyTable: ["표 찾기", "Table lookup"], + Ho_QtyFormula: ["식", "Formula"], + Ho_PriceLater: ["계산 때", "At calc"], + Ho_Missing: ["없는 요소", "Missing element"], + Middle_Title: ["중간 값", "Intermediate values"], + Result_Title: ["결과 식(돈 아닌 로직)", "Result formula (non-money)"], + Extra_Title: ["덧줄", "Extra lines"], + Rounding: ["끝수", "Rounding"], + Formula: ["식", "Formula"], + AddRow: ["줄 더하기", "Add line"], + DeleteRow: ["줄 지우기", "Delete line"], + Calc_Title: ["시험 계산", "Test calculation"], + Calc_Run: ["계산", "Calculate"], + Calc_NoInputs: ["받을 값 없음", "No inputs"], + Calc_Draft: ["고친 줄로 계산(저장 전)", "Using unsaved edits"], + Calc_Stopped: ["멈춤", "Stopped"], + Calc_Result: ["결과", "Result"], + Calc_Sum: ["계", "Total"], + Pick_Title: ["요소 찾기", "Find element"], + Pick_Search: ["이름·열쇠", "Name · key"], + Pick_Total: ["{n} 개 중 앞 100 개", "first 100 of {n}"], + Pick_Close: ["닫기", "Close"], + Save_Done: ["저장했음", "Saved"], + Save_Stale: [ + "그 사이 파일이 바뀜 — 고친 것을 버리고 다시 여세요: {v}", + "Files changed meanwhile — discard and reopen: {v}", + ], + Save_Errors: ["검사에 걸려 저장 안 함", "Check failed — nothing saved"], + Save_Failed: ["저장 못 함", "Save failed"], + Save_Nothing: ["고친 것 없음", "Nothing to save"], + Load_Failed: ["불러오지 못함", "Load failed"], + Confirm_Delete: [ + "로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐", + "Delete logic “{v}”? Removed on save", + ], + Confirm_Discard: ["고친 것을 모두 버릴까요?", "Discard all edits?"], + Confirm_Leave: [ + "저장 안 한 로직이 있음 — 다른 로직을 열어도 고친 것은 남음", + "Unsaved logics stay in the draft", + ], + New_Key: ["새 로직", "New logic"], +} as const satisfies Record; + +export type TextKey = keyof typeof TEXT; + +/** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */ +export function tx(key: TextKey, fill: Record = {}): string { + const entry = TEXT[key]; + const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0]; + return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); +} diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index aa0d76eb..90c3c2ab 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -21,21 +21,24 @@ | `GET /tables` | `file` · `q` | `{file, version, tables: [{열쇠, 이름, 기준, 출처, 조건, 값칸, count}]}` | | `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` | | `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 열쇠, 이름, 결과단위, 출처, blocked, reasons}]}` | -| `GET /logic` | `book` · `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons}` | +| `GET /logic` | `book` · `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` | +| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 이름, 규격, 단위, 값, 값칸}]}` | - `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄. - `q` = 이름 찾기 — `열쇠`·`이름` 에 든 글(대소문자 무시). - `chapter` = 파일 이름의 장(`공통3장` · `13장` · `01장`) · 없으면 "". - `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글. +- `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 열쇠·하위 로직 줄은 빠짐(계산 때 정해짐). +- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 `그룹:원문:열쇠` · 표형 그룹은 `값칸` 이 옴. ## 3. 시험 계산 -`POST /calc` 받음 `{book, key, inputs: {이름: 값}}` +`POST /calc` 받음 `{book, key, inputs: {이름: 값}, row?, file?}` - 됨 — `{ok: true, lines: [{이름, 단위, 수량, 단가, 금액, 비목: {비목: 금액}}], sums: {노무비, 재료비, 경비, 계}, middle: {이름: 값}}` - 돈 아닌 로직 — `{ok: true, result, middle}` - 멈춤 — `{ok: false, reason}` (200). 까닭 = 엔진 글 그대로(「입력 「돌」 없음」 · 「값 없음 — 관리자가 채울 값」 …). -- 계산은 저장된 파일로만 — 화면 캐시의 고친 값은 [저장] 뒤에 반영. +- `row` 없음 = 저장된 파일로 계산 · `row` 있음 = 그 로직만 고친 줄로 바꿔(없던 로직이면 `file` 에 더해) 메모리에서 계산 — 파일에 안 씀. 요소 값은 늘 저장된 파일. ## 4. 저장 diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 35c73fdc..dc7621d6 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -163,6 +163,28 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None: ) +def test_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) -> None: + one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY) + labor = one["prices"]["인력:건설노임:1016"] + assert labor["이름"] == "화약취급공" and labor["값"] > 0 + found = _get(client, "/api/m01/elements", group="인력", q="보통인부") + assert "인력:건설노임:1002" in [x["ref"] for x in found["items"]] + tables = _get(client, "/api/m01/elements", group="소요량", q="암발파", limit=5) + assert tables["items"] and all(x["값칸"] for x in tables["items"]) + raw = (store.FOLDER / one["file"]).read_bytes() + before = _calc(client)["sums"]["계"] + half = {**one["logic"], "호표": one["logic"]["호표"][:1]} # 보통인부 줄 뺌 + res = client.post( + "/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": {}, "row": half} + ) + lines = res.json()["lines"] + assert [x["이름"] for x in lines] == ["화약취급공"] and res.json()["sums"]["계"] < before + new = {**half, "열쇠": "새 로직"} + body = {"book": LOGIC_BOOK, "key": "새 로직", "inputs": {}, "row": new, "file": one["file"]} + assert client.post("/api/m01/calc", json=body).json()["ok"] is True + assert (store.FOLDER / one["file"]).read_bytes() == raw # 시험 계산은 안 씀 + + def test_쓰기_모양은_읽은_값과_같음() -> None: for name in ("소요량_건설품셈_기계설비13장.json", "로직_건설품셈_토목2장.json", LABOR): data = json.loads((REAL / name).read_text(encoding="utf-8"), parse_float=Decimal)