From a0bd269d7ffe4f4d84e54c305e6219e3469df365 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:13:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(710-2):=20M01=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=80=94=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=EC=A4=84=20=EB=B6=84=ED=95=A0=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EB=8D=A9=EC=9D=B4=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EB=B3=B8=EC=A0=95=EB=B3=B4=20=EC=A0=91?= =?UTF-8?q?=ED=9E=98=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20=EC=A7=80=EA=B8=88?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M01_MasterData_UI_LogicLab_View.ts — 보기 넷(단가산출 상세 기본 · 인력·재료·경비 준비 중 · 시험 계산 · 텍스트 수식) · [신규 로직] 왼쪽 세로 나눔선 - 덩이 이름표 data-block + 그릇 data-view CSS 숨김 — 시험 계산은 숨어도 그려 두어 자동 계산 돎 · 로직 바꿔도 보기 유지 - 흐름 로직(FlowView) 두 덩이도 같은 이름표 - 기본정보 = 맨 위 접힌 한 줄(키 · 원문번호 · 이름 · 결과단위) · 막힘 까닭은 접어도 보임 - 단가산출 상세 머리 「지금 입력」 한 줄(읽기만) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 5 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 93 ++++++++++++------- .../M01_MasterData_UI_LogicLab_FlowView.ts | 11 ++- .../M01_MasterData_UI_LogicLab_Style.css | 25 +++++ .../M01_MasterData_UI_LogicLab_Text.ts | 8 ++ .../M01_MasterData_UI_LogicLab_View.ts | 50 ++++++++++ 6 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_View.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index f8cd1b47b..877fb2cf8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -52,6 +52,7 @@ import { } from "./M01_MasterData_UI_LogicLab_Flow"; import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; +import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; @@ -130,6 +131,7 @@ export async function mountM01LogicLab( const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유) const panes = buildLogicSide(editor); + const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽 const errors = el("div", { className: "ui-banner ui-banner--danger m01-logic__reasons", attrs: { hidden: "" }, @@ -236,7 +238,7 @@ export async function mountM01LogicLab( list.root.hidden = !listing || !loaded; waiting.hidden = loaded; panes.root.hidden = !opened; - back.hidden = listing; + back.hidden = views.root.hidden = listing; if (!opened) return; if (!opened.row) { editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true })); @@ -582,6 +584,7 @@ export async function mountM01LogicLab( title: tx("Title"), status: [pending], buttons: [ + views.root, createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }), copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 023fc34d0..dac172596 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -37,6 +37,8 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; import { createBadge } from "@ui/ui_template_badge"; +import { createEmpty } from "@ui/ui_template_empty"; +import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; /** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] { @@ -142,7 +144,7 @@ const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => ], }); -function infoBox(ctx: DetailContext): HTMLElement { +function infoBox(ctx: DetailContext): HTMLElement[] { const row = ctx.row; const own = isOwnLogic(row); const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => { @@ -189,32 +191,46 @@ function infoBox(ctx: DetailContext): HTMLElement { }), ] : []; - return section(tl("Info_Title"), [ - el("p", { - className: "ui-text ui-text--muted", - text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"), - }), - ...reasons, - el("div", { - className: "m01lab__info", - children: [ - cell(tx("Head_File"), ctx.file), - cell(tx("Head_Key"), row.키), - field(tx("Head_Number"), "원문번호"), - field(tx("Head_Name"), "이름"), - field(tx("Head_Unit"), "결과단위"), - field(tx("Head_Source"), "출처"), - cell(tx("Head_Owner"), row.소유 ?? ""), - el("label", { - className: "m01lab__cell m01lab__cell--wide", - children: [ - el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }), - note, - ], - }), - ], - }), - ]); + const grid = el("div", { + className: "m01lab__info", + children: [ + cell(tx("Head_File"), ctx.file), + cell(tx("Head_Key"), row.키), + field(tx("Head_Number"), "원문번호"), + field(tx("Head_Name"), "이름"), + field(tx("Head_Unit"), "결과단위"), + field(tx("Head_Source"), "출처"), + cell(tx("Head_Owner"), row.소유 ?? ""), + el("label", { + className: "m01lab__cell m01lab__cell--wide", + children: [ + el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }), + note, + ], + }), + ], + }); + // 늘 맨 위 접힌 한 줄 — 펼침은 이 로직 안에서 기억(다시 그려도) · 막힘 까닭은 접어도 보임 + const head = [row.키, row.원문번호, row.이름, row.결과단위].filter(Boolean).join(" · "); + const fold = el("details", { + className: "m01lab__box m01lab__infofold", + attrs: ctx.ownHost.dataset.info ? { open: "" } : {}, + children: [ + el("summary", { + children: [ + el("strong", { text: tl("Info_Title") }), + el("span", { className: "ui-text ui-text--muted", text: head }), + ], + }), + el("p", { + className: "ui-text ui-text--muted", + text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"), + }), + grid, + ], + }); + fold.addEventListener("toggle", () => (ctx.ownHost.dataset.info = fold.open ? "1" : "")); + return [fold, ...reasons]; } /** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */ @@ -453,10 +469,13 @@ function ownEditor(ctx: DetailContext): HTMLElement { function hoBox(ctx: DetailContext): HTMLElement { const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원"); if (!money) { - return section(tl("Ho_Title"), [ - el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }), - el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }), - ]); + return block( + section(tl("Ho_Title"), [ + el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }), + el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }), + ]), + "ho", + ); } const all = entries(ctx); const calculated = ctx.lines !== null; @@ -479,7 +498,7 @@ function hoBox(ctx: DetailContext): HTMLElement { fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : "")); const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine]; const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; - return section(tl("Ho_Title"), blocks, hint); + return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho"); } /** 상세 화면 넷을 `host` 에 쌓음 */ @@ -492,10 +511,14 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ? { node: active, from: active.selectionStart, to: active.selectionEnd } : null; + // 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠 host.replaceChildren( - infoBox(ctx), - section(tl("Calc_Title"), [ctx.calcHost]), - ...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]), + ...infoBox(ctx), + block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), + block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"), + ...(ctx.flowHost + ? [block(nowInputs(ctx.values), "ho"), ctx.flowHost] + : [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 const back = own ? host.querySelector(`[data-own="${own}"]`) : kept?.node; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts index f4eb8eb86..16520184e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -159,9 +159,11 @@ export function buildFlowView( try { ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics }); } catch (e) { - host.replaceChildren( - section(tf("Detail"), [el("p", { className: "ui-text ui-text--muted", text: String(e) })]), - ); + const failed = section(tf("Detail"), [ + el("p", { className: "ui-text ui-text--muted", text: String(e) }), + ]); + failed.dataset.block = "ho"; + host.replaceChildren(failed); return; } const totalRound = totalRoundOf(lg, totalRoundNow()); @@ -196,5 +198,8 @@ export function buildFlowView( tf("Formula"), bodies.length ? bodies : [el("p", { className: "ui-text ui-text--muted", text: tf("NoFunc") })], ); + // 보기 고르기 이름표(`LogicLab_View.ts`) — 고른 덩이만 보임 + detail.dataset.block = "ho"; + text.dataset.block = "formula"; host.replaceChildren(detail, text); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index e9779d7e2..0a8e482bc 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -190,3 +190,28 @@ .m01lab__pickrow.is-picked { font-weight: 600; } + +/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */ +.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]), +.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]), +.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]), +.m01-logic__editor[data-view="formula"] [data-block]:not([data-block="formula"]) { + display: none; +} + +/* 기본정보 — 늘 맨 위 접힌 한 줄 */ +.m01lab__infofold > summary { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + cursor: pointer; +} + +.m01lab__infofold[open] > summary { + margin-bottom: var(--spacing-8); +} + +/* 상세 머리 「지금 입력」 한 줄 */ +.m01lab__now { + margin: 0; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 5baa2b5d1..1b977355b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -28,6 +28,14 @@ const TEXT = { Formula_Title: ["텍스트 수식", "Text formula"], Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"], Calc_Title: ["시험 계산", "Trial calculation"], + View_Est: ["인력·재료·경비", "Labor·material·expense"], + View_Label: ["보기", "View"], + View_Soon: [ + "준비 중 — 인력 · 재료 · 경비 묶음 보기", + "Coming soon — labor · material · expense view", + ], + Now_Inputs: ["지금 입력", "Current inputs"], + Now_None: ["없음", "none"], G_labor: ["인력", "Labor"], G_material: ["재료", "Material"], G_cost: ["경비", "Expense"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts new file mode 100644 index 000000000..577eb6d07 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts @@ -0,0 +1,50 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_View.ts + * 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」 + * 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임 + * 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지 + * 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위) + * 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세) + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createSegment } from "@ui/ui_template_segment"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +export type LabView = "ho" | "est" | "calc" | "formula"; + +/** 덩이에 보기 이름표를 붙여 돌려줌 */ +export const block = (node: T, view: LabView): T => { + node.dataset.block = view; + return node; +}; + +/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */ +export function createViewPicker(host: HTMLElement): { root: HTMLElement } { + host.dataset.view = "ho"; + const segment = createSegment({ + options: [ + { value: "ho", label: tl("Ho_Title") }, + { value: "est", label: tl("View_Est") }, + { value: "calc", label: tl("Calc_Title") }, + { value: "formula", label: tl("Formula_Title") }, + ], + value: "ho", + size: 32, + ariaLabel: tl("View_Label"), + onChange: (v) => (host.dataset.view = v), + }); + return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) }; +} + +/** 상세 머리 「지금 입력」 한 줄(읽기만) — 숨은 시험 계산 칸에 든 값 */ +export function nowInputs(values: Record): HTMLElement { + const filled = Object.entries(values).filter(([, v]) => String(v ?? "").trim() !== ""); + const text = filled.length ? filled.map(([k, v]) => `${k} ${v}`).join(" · ") : tl("Now_None"); + return el("p", { + className: "ui-text ui-text--muted m01lab__now", + attrs: { "data-now": "" }, + text: `${tl("Now_Inputs")} · ${text}`, + }); +} From 49538487688410121b6864e57d97e1df91ebaace Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:16:23 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(711-1):=20M01=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=E2=80=94=20=EC=98=B5=EC=85=98=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=20=EA=B2=A9=EC=9E=90=20=EB=91=98=20+=20?= =?UTF-8?q?=EC=88=98=ED=8F=89=20=EA=B5=AC=EB=B6=84=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=204=20=EC=97=B4=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=9D=B4=EB=A6=84=20=EB=88=84=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옵션 고르기 · 자재 고르기 사이 709-1 수평 구분선 부품(createDivider) - 기본 4 열 · 긴 이름 칸 두 칸 - [품목 고르기] 단추 걷음 — 잡힌 품목 이름 링크를 누르면 모달 · 고르면 다시 셈 Co-Authored-By: Claude Opus 5.5 --- .../M01_MasterData_UI_Logic_Calc.ts | 13 ++++++-- .../M01_MasterData_UI_Logic_CalcPick.ts | 32 +++++++++++-------- .../M01_MasterData_UI_Logic_Style.css | 17 ++++++++-- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 4aa40833c..6bfa49452 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -28,6 +28,7 @@ import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { createBadge } from "@ui/ui_template_badge"; +import { createDivider } from "@ui/ui_template_divider"; /** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */ const KEY_RE = /^[A-Z]{2}\d{6}$/; @@ -67,6 +68,9 @@ export interface CalcContext { onText?: (answer: TextAnswer) => void; } +const grid = (children: HTMLElement[]): HTMLElement => + el("div", { className: "m01-logic__head", children }); + const SUMS = ["노무비", "재료비", "경비", "계"]; /** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) */ @@ -122,7 +126,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { control = box; } const label = spec.단위 ? `${name} (${spec.단위})` : name; - // 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히 + // 긴 이름 줄만 두 칸 차지 — 짧으면 4 열에 맞춰 나란히 const wide = label.length > 10; return el("label", { className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`, @@ -196,8 +200,13 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { runButton.setAttribute("data-calc-run", ""); host.replaceChildren( el("h3", { text: tx("Calc_Title") }), + // 옵션 고르기 · 자재 고르기 두 격자(기본 4 열) — 사이 수평 구분선(709-1 부품) ...(fields.length || materials.length - ? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열 + ? [ + ...(fields.length ? [grid(fields)] : []), + ...(fields.length && materials.length ? [createDivider()] : []), + ...(materials.length ? [grid(materials)] : []), + ] : [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_NoInputs") })]), runButton, out, diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts index 227dc95df..1b608b92e 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_Logic_CalcPick.ts - * 시험 계산의 재료 · 기계 고르기 — 재료 줄마다 「잡힌 품목 + 품목 고르기」 · 기계 입력은 단추 하나 + * 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나 * 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함 * ========================================================================== */ @@ -49,10 +49,11 @@ const isMachineInput = (spec: LogicInput): boolean => const pickButton = (label: string, onClick: () => void): HTMLButtonElement => createButton({ label, variant: "ghost", onClick }); -const line = (label: string, current: HTMLElement, button: HTMLElement): HTMLElement => +/** 자재 칸 — 이름 + 잡힌 품목(누르면 고르기 모달) · 긴 이름은 두 칸 */ +const line = (label: string, current: HTMLElement): HTMLElement => el("div", { - className: "m01-logic__field m01-logic__field--wide", - children: [el("span", { text: label }), el("div", { children: [current, button] })], + className: `m01-logic__field${label.length > 10 ? " m01-logic__field--wide" : ""}`, + children: [el("span", { text: label }), current], }); /** 재료 줄마다 한 칸 — 지금 잡힌 품목 + 품목 고르기 */ @@ -80,12 +81,16 @@ export function materialRows( const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label] .map(headWord) .filter((w, k, all) => w && all.indexOf(w) === k); - const current = el("span", { - className: "ui-text ui-text--muted", - text: `${nowName || tx("Mat_NoPrice")} `, - attrs: { "data-mat-idx": String(i), "data-ref": brief?.ref ?? "" }, + // 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음) + const current = createButton({ + label: nowName || tx("Mat_NoPrice"), + variant: "link", + onClick: () => pick(), }); - const button = pickButton(tx("Pick_Item"), () => + current.dataset.matIdx = String(i); + current.dataset.ref = brief?.ref ?? ""; + current.title = tx("Pick_Item"); + const pick = (): void => openPickTable({ kind: "재료", title: `${tx("Pick_MatTitle")} · ${label}`, @@ -94,12 +99,11 @@ export function materialRows( isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref), onPick: (r) => { mine.set(i, { ref: String(r["키"]), label: nameOf(r) }); - current.textContent = `${nameOf(r)} `; + current.textContent = nameOf(r); again(); }, - }), - ); - return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current, button); + }); + return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current); }); } @@ -115,7 +119,7 @@ export function showItems( const item = lines[i]?.품목; if (!item || mine.has(i)) return; span.dataset.ref = item.키; - span.textContent = `${`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키} `; + span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키; }); } diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css index 5d5a9e7ef..287baba33 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -73,11 +73,24 @@ gap: var(--spacing-8); } -/* 시험 계산 입력만 2열 고정 — 요소 표 쪽(auto-fill)은 그대로 둠 +/* 시험 계산 입력만 4열 고정(옵션 · 자재 격자 둘) — 요소 표 쪽(auto-fill)은 그대로 둠 · 긴 이름 칸은 두 칸 실제 그릇은 LogicLab 이 씌우는 `.m01lab__calc`(`.m01-logic__calc` 는 옛 테스트 컨테이너 몫이라 안 걸림) */ .m01lab__calc .m01-logic__head, .m01-logic__calc .m01-logic__head { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.m01lab__calc .m01-logic__field--wide { + grid-column: span 2; +} + +/* 자재 칸 잡힌 품목 링크 — 칸 폭 안에서 왼쪽 맞춤 · 넘치면 말줄임 */ +.m01lab__calc .m01-logic__field > .ui-btn--link { + justify-content: flex-start; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .m01-logic__field { From 0c956a6c61a3e32ae789517e1905b33ed7852ee6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:26:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(711-2):=20M01=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=95=98=EB=82=98=20=E2=80=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=91=9C=20=EC=A4=84=20=C2=B7=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EC=9E=90=EC=9E=AC=20=EC=B9=B8=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EB=AC=BC?= =?UTF-8?q?=EA=B0=80=20=EB=84=B7=20=EA=B0=80=EB=A1=9C=20=C2=B7=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 쪽 openMaterialModal 기준 — 위 물가자료 · 유통물가 · 물가정보 · 거래가격 가로 넷(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) - 후보 = /materials(조건 · 단위 거름) · 조건 없으면 이름 찾기 · 찾기 칸(검색어로 먼저 거름 · 없으면 재료 전체 찾기) · 목록 값 열 뺌 - 줄 누르면 고름(시험 입력 · 저장 안 함) → 다시 셈 · 상세 표 재료 줄에서 골라도 시험 계산 자재 칸 따라감 - 기계 고르기 · M02 일위대가 [바꾸기]는 PickTable 그대로 Co-Authored-By: Claude Opus 5.5 --- .../M01_MasterData_UI_LogicLab_Detail.ts | 9 +- .../M01_MasterData_UI_LogicLab_Modal.ts | 276 +++++++++++++----- .../M01_MasterData_UI_LogicLab_Style.css | 10 + .../M01_MasterData_UI_LogicLab_Text.ts | 5 + .../M01_MasterData_UI_Logic_CalcPick.ts | 46 ++- 5 files changed, 257 insertions(+), 89 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index dac172596..b67d95ca6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -22,6 +22,7 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; +import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { condText, @@ -281,7 +282,7 @@ function entries(ctx: DetailContext): Entry[] { open: (self) => openMaterialModal({ unitFrag: self.unitFrag, - unitRef: self.unitRef, + unitRef: line?.품목?.키 ?? self.unitRef, unitText: self.unitText, qtyFrag: self.piecesFrag, title: item.이름 ?? (cond ? condLabel(cond) : item.요소), @@ -290,6 +291,12 @@ function entries(ctx: DetailContext): Entry[] { prices: ctx.prices, middles, values: ctx.values, + // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) + kind: item.종류, + usedSlot: line?.품목?.값칸, + ...(item.종류 === "재료" + ? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) } + : {}), }), }; return spread(entry, line); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 161995cd6..f28b870fd 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -3,18 +3,23 @@ * 자료 모달 둘 — * ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 * 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식 + * 재료 줄 = 위 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) + 후보 목록(찾기 · `onPick` 이면 누르면 고름) + * — 상세 표 줄 · 시험 계산 자재 칸이 같은 모달(711-2) · 기계 고르기는 `Logic_PickTable` 그대로 * ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임 * 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 * 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices` * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { + fetchMaterials, searchElements, type ElementBrief, type NamedFormula, } from "./M01_MasterData_UI_Logic_Api"; +import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch"; +import { createStat, statGrid } from "@ui/ui_template_stat"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note"; @@ -81,6 +86,12 @@ export interface ModalTarget { unitText?: string; /** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */ qtyFrag?: Fragment[]; + /** 호표 줄 종류 — 「재료」면 후보 목록(고르기 조건 줄이 아니어도) */ + kind?: string; + /** 시험 계산이 쓴 값 칸(계산 답 `품목.값칸`) — 위 물가 넷 가운데 굵게 */ + usedSlot?: string; + /** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */ + onPick?: (item: { ref: string; label: string }) => void; } /** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ @@ -176,7 +187,71 @@ function goButton(target: MasterJump): HTMLElement { }); } -function priceView(ref: string, brief: ElementBrief): HTMLElement { +/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */ +const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격"]; +const MARKET_FILE = "재료_자재품목.json"; +const marketRows = new Map>(); +/** 자재품목 줄 하나(값 칸 넷) — 키로 찾음 · 한 번만 물음 */ +const marketRow = (ref: string): Promise => { + if (!marketRows.has(ref)) + marketRows.set( + ref, + fetchRows(MARKET_FILE, 1, 5, ref) + .then((got) => got.rows.find((r) => r["키"] === ref) ?? null) + .catch(() => null), + ); + return marketRows.get(ref)!; +}; + +/** 자재품목 줄 단가 — 이름 · 키 · 규격 + 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게 · 모르면 낮은 값) */ +function marketView( + ref: string, + brief: ElementBrief | null, + usedSlot?: string, + onValue?: (value: unknown) => void, +): HTMLElement { + const head = el("strong"); + const spec = el("p", { className: "ui-text ui-text--muted" }); + const slots = el("div"); + const paint = (name: unknown, size: unknown, values: Record): void => { + head.textContent = `${String(name ?? ref)} · ${ref}`; + spec.textContent = size ? `${tl("Modal_Spec")} ${String(size)}` : ""; + spec.hidden = !size; + const nums = SLOTS.map((s) => values[s]).filter((v): v is number => typeof v === "number"); + const used = SLOTS.includes(usedSlot ?? "") + ? usedSlot + : SLOTS.find((s) => nums.length && values[s] === Math.min(...nums)); + slots.replaceChildren( + statGrid( + SLOTS.map((s) => { + const card = createStat({ + label: s, + value: values[s] === null || values[s] === undefined ? "-" : formatPrice(values[s]), + compact: true, + }); + card.classList.toggle("is-used", s === used); + return card; + }), + ), + ); + if (used) onValue?.(values[used]); + }; + paint(brief?.이름, brief?.규격, brief?.값들 ?? {}); + if (!brief?.값들) void marketRow(ref).then((row) => row && paint(row["이름"], row["규격"], row)); + return el("div", { + className: "m01lab__data m01lab__slots", + attrs: { "data-price": ref }, + children: [ + head, + spec, + slots, + goButton({ kind: "element", group: "재료", ref, file: brief?.file ?? MARKET_FILE }), + ], + }); +} + +function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLElement { + if (ref.startsWith("MT")) return marketView(ref, brief, usedSlot); const cols = Object.entries(brief.값칸 ?? {}); const slots = Object.entries(brief.값들 ?? {}).filter(([, v]) => v !== null && v !== undefined); return el("div", { @@ -207,8 +282,8 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { }); } -/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */ -function openDialog(title: string, mount: (body: HTMLElement) => void): void { +/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */ +function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void { openModal({ title, closeLabel: tl("Modal_Close"), @@ -220,58 +295,122 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void { const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text }); -/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */ -function candidateView( - cond: PickCond, - pickedRef: string, - onPicked: (item: ElementBrief | null) => void, -): HTMLElement { - const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } }); - box.append(muted("…")); - void searchElements("재료", seedOf(cond)) +const free = (v: unknown): string => { + const text = String(v ?? ""); + return text.includes("{") ? "" : text; // `{입력}` 자리는 계산 때 정해짐 — 거름에 안 씀 +}; +const squash = (v: unknown): string => + String(v ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 재료 줄 후보 목록 — 고르기 조건 안 후보(`/materials` · 단위 거름) · 조건이 없으면 이름 찾기 · + * 찾기 칸 = 받은 후보 안에서 거름(없으면 재료 전체 이름 찾기) · 잡힌 품목(`pickedRef`) 노란 줄 맨 위 · + * `onPick` 이면 줄을 누르면 고름 · 값은 위 가로 칸이 보여 목록엔 값 열 없음 */ +function candidateView(target: ModalTarget, pickedRef: string, close: () => void): HTMLElement { + const cond = target.cond; + const sub = free(cond?.구분); + const list = el("div"); + const count = el("strong"); + const field = createInputField({ type: "search", placeholder: tl("Modal_Find") }); + const box = el("div", { + className: "m01lab__data", + attrs: { "data-candidates": "1" }, + children: [count, field.root, list], + }); + list.append(muted("…")); + let base: ElementBrief[] = []; + const draw = (items: ElementBrief[], total: number): void => { + const sorted = [ + ...items.filter((it) => it.ref === pickedRef), + ...items.filter((it) => it.ref !== pickedRef), + ]; + const more = total > items.length ? ` (${items.length})` : ""; + count.textContent = `${tl(target.onPick ? "Modal_PickList" : "Modal_Candidates")} ${total}${more}`; + const rows = sorted.map((it) => { + const hit = it.ref === pickedRef; + const tr = el("tr", { + className: `${hit ? "m01lab__hit" : ""}${target.onPick ? " m01lab__pickrow" : ""}`, + attrs: { ...(hit ? { "data-hit": "1" } : {}), "data-ref": it.ref }, + children: [ + el("td", { text: it.이름 ?? it.ref }), + el("td", { text: it.규격 ?? "" }), + el("td", { text: String(it.단위 ?? "") }), + ], + }); + const onPick = target.onPick; + if (onPick) + tr.addEventListener("click", () => { + close(); + onPick({ ref: it.ref, label: `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() || it.ref }); + }); + return tr; + }); + list.replaceChildren( + rows.length + ? el("div", { + className: "m01-logic__scroll m01lab__cands", + children: [ + el("table", { + className: "ui-table ui-table--data m01-logic__grid m01lab__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tl("Col_Name"), tl("Modal_Spec"), tl("Col_Unit")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ], + }) + : muted(tl("Pill_NoPick")), + ); + }; + // 조건 없는 재료 줄 = 그 요소 이름 첫 낱말로 찾음(「모르타르, 서울 1:1」 → 「모르타르」) + const known = target.element ? target.prices[target.element] : null; + const seed = cond ? seedOf(cond) : headWord(known?.이름 ?? target.title).replace(/[,,].*$/, ""); + const first = sub + ? fetchMaterials({ + sub, + detail: free(cond?.상세구분), + spec: free(cond?.규격), + unit: target.unit ?? "", + }) + : searchElements("재료", seed); + let total = 0; + let timer = 0; + let seq = 0; + /** 찾기 칸 글로 거름 — 받은 후보 안에서 · 하나도 없으면 재료 전체 이름 찾기 */ + const apply = (): void => { + const mine = ++seq; + const q = field.input.value.trim(); + const words = q.split(/\s+/).map(squash).filter(Boolean); + if (!words.length) return draw(base, total); + const hits = base.filter((it) => + words.every((w) => squash(`${it.이름 ?? ""} ${it.규격 ?? ""} ${it.ref}`).includes(w)), + ); + if (hits.length) return draw(hits, hits.length); + void searchElements("재료", q) + .then((got) => mine === seq && draw(got.items, got.total)) + .catch(() => undefined); + }; + void first .then((got) => { - onPicked(got.items.find((it) => it.ref === pickedRef) ?? null); - const rows = got.items.map((it) => - el("tr", { - className: it.ref === pickedRef ? "m01lab__hit" : "", - attrs: it.ref === pickedRef ? { "data-hit": "1" } : {}, - children: [ - el("td", { text: it.이름 ?? it.ref }), - el("td", { text: it.규격 ?? "" }), - el("td", { text: String(it.단위 ?? "") }), - el("td", { className: "m01-logic__money", text: formatPrice(it.값) }), - ], - }), - ); - const more = got.total > got.items.length ? ` (${got.items.length})` : ""; - box.replaceChildren( - el("strong", { text: `${tl("Modal_Candidates")} ${got.total}${more}` }), - el("div", { - className: "m01-logic__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m01-logic__grid m01lab__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: [ - tl("Col_Name"), - tl("Modal_Spec"), - tl("Col_Unit"), - tl("Modal_Value"), - ].map((h) => el("th", { text: h })), - }), - ], - }), - el("tbody", { children: rows }), - ], - }), - ], - }), - ); + base = got.items; + total = got.total; + field.input.value = sub ? free(cond?.["검색어"]) : ""; // 엔진이 고르는 검색어로 먼저 거름 · 지우면 조건 안 전부 + apply(); }) - .catch(() => box.replaceChildren(muted(tl("Pill_NoPick")))); + .catch(() => list.replaceChildren(muted(tl("Pill_NoPick")))); + field.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(apply, 300); + }); return box; } @@ -285,7 +424,7 @@ function pickedOf(cond: PickCond, values: Record, fallback: stri /** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) · * 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */ export function openMaterialModal(target: ModalTarget): void { - openDialog(target.title, (body) => { + openDialog(target.title, (body, close) => { const cond = target.cond; const element = target.element ?? ""; const base = element.split(".")[0]; @@ -312,7 +451,7 @@ export function openMaterialModal(target: ModalTarget): void { setSum(known); const rate = el("div", { className: "m01lab__modal-body" }); if (known && priceRef) { - rate.append(priceView(priceRef, known)); + rate.append(priceView(priceRef, known, target.usedSlot)); } else if (logicKey) { rate.append( el("p", { @@ -327,7 +466,12 @@ export function openMaterialModal(target: ModalTarget): void { }), ); } else if (cond && priceRef) { - rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴 + // 서버가 잡은 품목 · 고른 품목 — 자재품목 줄을 키로 읽어 물가 넷 + rate.append( + marketView(priceRef, null, target.usedSlot, (value) => + setSum({ ref: priceRef, 값: value } as ElementBrief), + ), + ); } else if (cond) { rate.append(muted(tl("Pill_NoPick"))); } else if (element.includes("{")) { @@ -358,23 +502,7 @@ export function openMaterialModal(target: ModalTarget): void { } body.replaceChildren( rate, - ...(cond - ? [ - candidateView(cond, priceRef, (item) => { - if (known || !priceRef) return; - const show = (brief: ElementBrief | null): void => { - rate.replaceChildren( - brief ? priceView(priceRef, brief) : muted(`${priceRef} — ${tl("Pill_NoValue")}`), - ); - setSum(brief); - }; - if (item) return show(item); - void searchElements("재료", priceRef) - .then((got) => show(got.items.find((it) => it.ref === priceRef) ?? null)) - .catch(() => show(null)); - }), - ] - : []), + ...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []), muted(tl("Modal_Sum")), sum, el("details", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 0a8e482bc..0292d5cd7 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -215,3 +215,13 @@ .m01lab__now { margin: 0; } + +/* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */ +.m01lab__slots .ui-stat:not(.is-used) .ui-stat__value { + font-weight: 400; +} + +.m01lab__cands { + max-height: 40vh; + overflow: auto; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 1b977355b..8ecee9c54 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -83,6 +83,11 @@ const TEXT = { "후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)", "Candidates (yellow = used in the trial)", ], + Modal_PickList: [ + "후보 목록 — 줄을 누르면 그 품목으로 시험 계산 (노란 줄 = 지금 쓰는 품목)", + "Candidates — click a row to use it in the trial (yellow = in use)", + ], + Modal_Find: ["이름 · 규격으로 찾기", "Search by name or spec"], Modal_Logic: ["다른 로직을 부름", "Calls another logic"], Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"], Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts index 1b608b92e..c7388dfa2 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M01_MasterData_UI_Logic_CalcPick.ts * 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나 - * 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함 + * 재료 = 상세 표 줄과 같은 재료 모달(`openMaterialModal` · 711-2) · 기계 = 마스터 표 모달(`PickTable`) + * 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함 * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; @@ -9,7 +10,7 @@ import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch"; import type { CalcLine, ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api"; import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal"; +import { condText, openMaterialModal, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal"; interface Chosen { ref: string; @@ -24,6 +25,19 @@ const picksOf = (values: Record): Map => { return mine; }; +/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */ +export function pickMaterial( + host: HTMLElement, + values: Record, + i: number, + it: Chosen, +): void { + picksOf(values).set(i, it); + const link = host.querySelector(`[data-mat-idx="${i}"]`); + if (link) link.textContent = it.label; + host.querySelector("[data-calc-run]")?.click(); +} + /** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */ export function pickBody(values: Record): { 고름?: Record } { const mine = picksOf(values); @@ -78,9 +92,6 @@ export function materialRows( const held = mine.get(i); const nowName = held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? ""; - const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label] - .map(headWord) - .filter((w, k, all) => w && all.indexOf(w) === k); // 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음) const current = createButton({ label: nowName || tx("Mat_NoPrice"), @@ -90,20 +101,26 @@ export function materialRows( current.dataset.matIdx = String(i); current.dataset.ref = brief?.ref ?? ""; current.title = tx("Pick_Item"); + const title = `${label}${item.규격 ? ` ${item.규격}` : ""}`; const pick = (): void => - openPickTable({ + openMaterialModal({ + title, + expr: item.수량, + ...(cond ? { cond } : { element: item.요소 }), + unit: item.단위, kind: "재료", - title: `${tx("Pick_MatTitle")} · ${label}`, - seed: seeds[0] ?? "", - alt: seeds.slice(1), - isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref), - onPick: (r) => { - mine.set(i, { ref: String(r["키"]), label: nameOf(r) }); - current.textContent = nameOf(r); + prices, + middles: row.중간 ?? [], + values, + unitRef: mine.get(i)?.ref || current.dataset.ref || undefined, + usedSlot: current.dataset.slot || undefined, + onPick: (it) => { + mine.set(i, it); + current.textContent = it.label; again(); }, }); - return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current); + return line(title, current); }); } @@ -117,6 +134,7 @@ export function showItems( host.querySelectorAll("[data-mat-idx]").forEach((span) => { const i = Number(span.dataset.matIdx); const item = lines[i]?.품목; + span.dataset.slot = item?.값칸 ?? ""; // 시험 계산이 쓴 값 칸 — 모달 위 가로 칸 굵게 if (!item || mine.has(i)) return; span.dataset.ref = item.키; span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;