diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M01_MasterData/M01_MasterData_UI_Combo.ts index 93e428dc..791ffb66 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo.ts @@ -12,12 +12,13 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api"; +import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { deleteCombo, + editCombo, fetchCombo, fetchCombos, - saveCombo, + newCombo, type Combo, type ComboSummary, } from "./M01_MasterData_UI_Combo_Api"; @@ -32,13 +33,25 @@ const FILTER_ID = "조합|"; const blankCombo = (): Combo => ({ 키: "", 이름: "", - 구분: "", - 상세구분: "", - 단위: "", - 비고: "", - 로직: [], + 구분: null, + 상세구분: null, + 단위: null, + 담은로직: [], + 출처: "자체", + 소유: "현장", + 비고: null, }); +/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */ +function reason(error: unknown): string { + if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { + const d = error.detail as { errors?: string[]; stale?: string[] }; + if (d.errors) return d.errors.join(" / "); + if (d.stale) return tc("Stale"); + } + return error instanceof Error ? error.message : tc("Load_Failed"); +} + export interface ComboHandle { /** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */ show: () => Promise; @@ -71,13 +84,15 @@ export function mountM01Combo( /* --- 왼쪽 거름 --- */ const drawFilter = (): void => { - const subs = [...new Set(items.map((x) => x.구분).filter(Boolean))].map((name) => ({ - name, - book: null, - details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter( - Boolean, - ), - })); + const subs = [...new Set(items.map((x) => x.구분).filter((v): v is string => !!v))].map( + (name) => ({ + name, + book: null, + details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter( + (v): v is string => !!v, + ), + }), + ); filterHost.replaceChildren( side.filter({ id: FILTER_ID, @@ -99,7 +114,11 @@ export function mountM01Combo( el("div", { className: "m01-logic__section-head", children: [ - createButton({ label: tc("New"), variant: "filled", onClick: () => draw(blankCombo()) }), + createButton({ + label: tc("New"), + variant: "filled", + onClick: () => draw(blankCombo(), ""), + }), ...more, ], }); @@ -120,8 +139,8 @@ export function mountM01Combo( const tr = el("tr", { className: "m01-master__table-row", attrs: { "data-combo-key": x.키 }, - children: [x.키, x.이름, x.구분, x.상세구분, x.단위, String(x.로직수)].map((v) => - el("td", { text: v }), + children: [x.키, x.이름, x.구분 ?? "", x.상세구분 ?? "", x.단위 ?? "", String(x.count)].map( + (v) => el("td", { text: v }), ), }); tr.addEventListener("click", () => void open(x.키)); @@ -150,41 +169,46 @@ export function mountM01Combo( const open = async (key: string): Promise => { try { - draw(await fetchCombo(key)); + const one = await fetchCombo(key); + draw(one.combo, one.version); } catch (error) { showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); } }; /* --- 상세 --- */ - const draw = (combo: Combo): void => { + const draw = (combo: Combo, version: string): void => { const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { const f = createInputField({ type: "text", label }); - f.input.value = combo[name]; + f.input.value = combo[name] ?? ""; f.input.setAttribute("data-combo-field", name); - f.input.addEventListener("input", () => (combo[name] = f.input.value)); + f.input.addEventListener("input", () => { + const v = f.input.value; + if (name === "이름") combo.이름 = v; + else combo[name] = v || null; + }); return f.root; }; - const preview = buildPreview(() => combo.로직); + const preview = buildPreview(() => combo); const table = el("div"); const drawTable = (): void => { const move = (i: number, d: number): void => { const j = i + d; - if (j < 0 || j >= combo.로직.length) return; - [combo.로직[i], combo.로직[j]] = [combo.로직[j], combo.로직[i]]; + if (j < 0 || j >= combo.담은로직.length) return; + [combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]]; drawTable(); }; - const trs = combo.로직.map((row, i) => { - const info = logics.get(row.키); + const trs = combo.담은로직.map((row, i) => { + const info = logics.get(row.로직); const memo = createInputField({ type: "text" }); - memo.input.value = row.메모; - memo.input.addEventListener("input", () => (row.메모 = memo.input.value)); + memo.input.value = row.메모 ?? ""; + memo.input.addEventListener("input", () => (row.메모 = memo.input.value || null)); const name = el("button", { className: "ui-btn ui-btn--ghost", - text: info?.이름 ?? row.키, + text: info?.이름 ?? row.로직, attrs: { type: "button", title: tc("Open") }, }); - name.addEventListener("click", () => onOpenLogic(row.키)); + name.addEventListener("click", () => onOpenLogic(row.로직)); const btns = el("div", { className: "m01-logic__chips", children: [ @@ -194,17 +218,17 @@ export function mountM01Combo( label: tc("Remove"), variant: "ghost", onClick: () => { - combo.로직.splice(i, 1); + combo.담은로직.splice(i, 1); drawTable(); }, }), ], }); return el("tr", { - attrs: { "data-combo-logic": row.키 }, + attrs: { "data-combo-logic": row.로직 }, children: [ el("td", { text: String(i + 1) }), - el("td", { text: row.키 }), + el("td", { text: row.로직 }), el("td", { children: [name] }), el("td", { text: info?.결과단위 ?? "" }), el("td", { children: [memo.root] }), @@ -241,7 +265,7 @@ export function mountM01Combo( onClick: () => openLogicPicker((x) => { if (!logics.has(x.키)) logics.set(x.키, x); - combo.로직.push({ 키: x.키, 메모: "", 입력: {} }); + combo.담은로직.push({ 로직: x.키, 메모: null }); drawTable(); }), }); @@ -251,12 +275,13 @@ export function mountM01Combo( onClick: async () => { if (!combo.이름.trim()) return showToast(tc("NeedName"), "error"); try { - const saved = await saveCombo(combo); + const key = combo.키 ? combo.키 : (await newCombo(combo)).key; + if (combo.키) await editCombo(combo.키, version, combo); showToast(tc("Saved"), "success"); await load(); - draw(saved); + await open(key); } catch (error) { - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + showToast(reason(error), "error"); } }, }); @@ -266,11 +291,11 @@ export function mountM01Combo( onClick: async () => { if (!combo.키 || !(await showConfirmDialog(tc("DeleteAsk")))) return; try { - await deleteCombo(combo.키); + await deleteCombo(combo.키, version); showToast(tc("Deleted"), "success"); await load(); } catch (error) { - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + showToast(reason(error), "error"); } }, }); diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts index cb0518e5..c0ea8a02 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts @@ -1,34 +1,72 @@ /* ============================================================================= * M01_MasterData_UI_Combo_Api.ts * 일위대가 조합 화면이 부르는 서버 길 — 조합 마스터 `일위대가조합.json`(키 UA) - * 길 이름은 계약(PLAN 4-2)이 오면 이 파일만 맞춤 · 미리 보기는 로직마다 `/calc` 를 불러 화면에서 합침 + * 길 = `_화면_계약.md` 9장 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { ApiError } from "./M01_MasterData_UI_Logic_Api"; -/** 조합에 담은 로직 한 줄 — 입력 = 그 로직을 셀 때 넣을 값 */ +/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ export interface ComboLogic { - 키: string; - 메모: string; - 입력: Record; + 로직: string; + 메모: string | null; } +/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */ export interface Combo { 키: string; 이름: string; - 구분: string; - 상세구분: string; - 단위: string; - 비고: string; - 로직: ComboLogic[]; + 구분: string | null; + 상세구분: string | null; + 단위: string | null; + 담은로직: ComboLogic[]; + 출처: string | null; + 소유: string | null; + 비고: string | null; } -export interface ComboSummary { - 키: string; +export interface ComboSummary extends Combo { + count: number; + blocked: boolean; + reasons: string[]; +} +/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */ +export interface ComboLine { + 차례: number; + 로직: string; + 메모: string | null; + ok: boolean; + 까닭?: string; + 이름?: string; + 구분?: string; + 상세구분?: string; + 결과단위?: string; +} +export interface ComboOne { + file: string; + version: string; + combo: Combo; + 줄: ComboLine[]; +} +export interface PreviewLine { + 차례: number; + 로직: string; 이름: string; - 구분: string; - 상세구분: string; - 단위: string; - 로직수: number; + ok: boolean; + 까닭?: string; + 갈래?: string; + 결과?: number; + 노무비?: number; + 재료비?: number; + 경비?: number; + 계?: number; +} +export interface PreviewAnswer { + 줄: PreviewLine[]; + 노무비: number; + 재료비: number; + 경비: number; + 계: number; + 멈춤: string[]; } async function request(path: string, body?: unknown): Promise { @@ -46,11 +84,23 @@ async function request(path: string, body?: unknown): Promise { export const fetchCombos = (): Promise => request<{ combos: ComboSummary[] }>("/combos").then((d) => d.combos); -export const fetchCombo = (key: string): Promise => - request<{ combo: Combo }>(`/combo?key=${encodeURIComponent(key)}`).then((d) => d.combo); +export const fetchCombo = (key: string): Promise => + request(`/combo?key=${encodeURIComponent(key)}`); -/** 키가 비면 새 조합 — 서버가 다음 번호(UA…)를 줌 */ -export const saveCombo = (combo: Combo): Promise => - request<{ combo: Combo }>("/combo/save", { combo }).then((d) => d.combo); +export const newCombo = (combo: Combo): Promise<{ file: string; version: string; key: string }> => + request("/combo/new", { combo, owner: "현장" }); -export const deleteCombo = (key: string): Promise => request("/combo/delete", { key }); +export const editCombo = ( + key: string, + version: string, + combo: Combo, +): Promise<{ file: string; version: string }> => request("/combo/edit", { key, version, combo }); + +export const deleteCombo = (key: string, version: string): Promise => + request("/combo/delete", { key, version }); + +/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ +export const previewCombo = ( + combo: Combo | string, + inputs: Record>, +): Promise => request("/combo/preview", { combo, inputs }); diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 99aa134f..becb0cf1 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -1,15 +1,15 @@ /* ============================================================================= * M01_MasterData_UI_Combo_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 입력값으로 `/calc` 를 불러 노무비·재료비·경비로 갈라 합침(보기만 · 저장 없음) + * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST /combo/preview`)에 실어 비목별 합계를 받음 + * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) * ========================================================================== */ import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import { fetchLogic, runCalc, type LogicInput } from "./M01_MasterData_UI_Logic_Api"; -import type { ComboLogic } from "./M01_MasterData_UI_Combo_Api"; +import { fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api"; +import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api"; import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text"; const COSTS = ["노무비", "재료비", "경비"] as const; -type Costs = Record<(typeof COSTS)[number], number>; const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { 노무비: "Prev_Labor", 재료비: "Prev_Material", @@ -18,25 +18,28 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); -const total = (c: Costs): number => c.노무비 + c.재료비 + c.경비; -const blank = (): Costs => ({ 노무비: 0, 재료비: 0, 경비: 0 }); /** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); -/** 입력 칸 — 바꾸면 그 줄의 입력에 바로 적음(조합과 함께 저장됨) */ -function inputCell(input: LogicInput, row: ComboLogic): HTMLElement { - const value = row.입력[input.이름] ?? initial(input); - row.입력[input.이름] = value; +/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */ +function inputCell( + input: LogicInput, + key: string, + values: Record>, +): HTMLElement { + const mine = (values[key] ??= {}); + const value = mine[input.이름] ?? initial(input); + mine[input.이름] = value; const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); const field = choices ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) : createInputField({ type: "text", label: input.이름 }); const control = "select" in field ? field.select : field.input; control.value = value; - control.setAttribute("data-input", `${row.키}|${input.이름}`); - control.addEventListener("input", () => (row.입력[input.이름] = control.value)); - control.addEventListener("change", () => (row.입력[input.이름] = control.value)); + control.setAttribute("data-input", `${key}|${input.이름}`); + control.addEventListener("input", () => (mine[input.이름] = control.value)); + control.addEventListener("change", () => (mine[input.이름] = control.value)); return field.root; } @@ -46,8 +49,11 @@ export interface PreviewHandle { redraw: () => Promise; } -export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { - const inputs = el("div", { className: "m01-logic__stack" }); +export function buildPreview(combo: () => Combo): PreviewHandle { + const values: Record> = {}; + /** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */ + const inputs = new Map(); + const form = el("div", { className: "m01-logic__stack" }); const out = el("div", { attrs: { "data-combo": "preview" } }); const rowOf = (cells: string[], note = "", bold = false): HTMLElement => el("tr", { @@ -57,34 +63,43 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { const run = async (): Promise => { out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); - const sum = blank(); - const trs: HTMLElement[] = []; - for (const row of rows()) { - const cost = blank(); - let name = row.키; - let note = ""; - try { - const one = await fetchLogic(row.키); - name = one.logic.이름; - const values: Record = {}; - for (const i of one.logic.입력) { - const raw = row.입력[i.이름] ?? initial(i); - if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) - values[i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); - } - const got = await runCalc({ key: row.키, inputs: values }); - if (got.ok) { - for (const line of got.lines ?? []) for (const c of COSTS) cost[c] += line.비목?.[c] ?? 0; - } else note = `${tc("Prev_Fail")}: ${got.reason}`; - } catch (error) { - note = `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}`; + const send: Record> = {}; + for (const [key, defs] of inputs) { + send[key] = {}; + for (const i of defs) { + const raw = values[key]?.[i.이름] ?? initial(i); + if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) + send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); } - for (const c of COSTS) sum[c] += cost[c]; - trs.push( - rowOf([`${row.키} ${name}`, ...COSTS.map((c) => fmt(cost[c])), fmt(total(cost))], note), - ); } - trs.push(rowOf([tc("Prev_Sum"), ...COSTS.map((c) => fmt(sum[c])), fmt(total(sum))], "", true)); + let got; + try { + got = await previewCombo(combo(), send); + } catch (error) { + out.replaceChildren( + el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), + ); + return; + } + const trs = got.줄.map((l) => + rowOf( + [ + `${l.로직} ${l.이름}`, + fmt(l.노무비 ?? 0), + fmt(l.재료비 ?? 0), + fmt(l.경비 ?? 0), + fmt(l.계 ?? 0), + ], + l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`, + ), + ); + trs.push( + rowOf( + [tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)], + "", + true, + ), + ); const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")]; out.replaceChildren( el("table", { @@ -108,26 +123,28 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { const redraw = async (): Promise => { const blocks: HTMLElement[] = []; - for (const row of rows()) { + inputs.clear(); + for (const row of combo().담은로직) { try { - const one = await fetchLogic(row.키); + const one = await fetchLogic(row.로직); + inputs.set(row.로직, one.logic.입력); blocks.push( el("div", { className: "m01lab__info", children: [ el("strong", { className: "m01lab__cell--wide", - text: `${row.키} ${one.logic.이름}`, + text: `${row.로직} ${one.logic.이름}`, }), - ...one.logic.입력.map((i) => inputCell(i, row)), + ...one.logic.입력.map((i) => inputCell(i, row.로직, values)), ], }), ); } catch { - /* 못 읽은 로직은 계산 때 까닭이 뜸 */ + /* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */ } } - inputs.replaceChildren(...blocks); + form.replaceChildren(...blocks); out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); }; @@ -139,7 +156,7 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { children: [el("h3", { text: tc("Prev_Title") })], }), el("h4", { text: tc("Prev_Inputs") }), - inputs, + form, button, out, ], diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts index 4d6bfad1..60ca1532 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts @@ -17,6 +17,7 @@ const TEXT = { DeleteAsk: ["이 조합을 지울까요?", "Delete this combination?"], Saved: ["저장함", "Saved"], Deleted: ["지움", "Deleted"], + Stale: ["그 사이 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"], NeedName: ["이름을 적어야 저장됨", "A name is required"], Load_Failed: ["불러오지 못함", "Load failed"], Head_Key: ["키", "Key"],