fix(M01): 잡힌 품목 이름 · 모달 검색 칸 괄호 앞 낱말 · 상세 재료 줄 모달 서버 품목 · 단위경고 딱지 상세 줄에도 (PLAN 8-5)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-24 22:07:11 +09:00
co-authored by Claude Sonnet 5
parent 3f7d2b386f
commit 8759bd7929
5 changed files with 59 additions and 20 deletions
@@ -106,6 +106,8 @@ interface Entry {
/** 서버 요소조각의 첫 요소 — 자리 표시 든 줄이 입력으로 풀린 키 · 글 */
unitRef?: string;
unitText?: string;
/** 자재 단위가 줄 단위와 성격이 다름 — 「줄 m · 자재 Ton」 */
warn?: string;
}
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
@@ -237,6 +239,7 @@ function entries(ctx: DetailContext): Entry[] {
qtyFrag: [{ text: item.수량 }],
price: price === undefined || price === null ? "" : formatPrice(price),
amount: line ? line.금액 : null,
warn: line?.단위경고,
extra: false,
open: (self) =>
openMaterialModal({
@@ -338,6 +341,14 @@ function lineRow(e: Entry): HTMLElement {
? [el("div", { className: "m01-logic__tag", text: `${e.share} ${tl("Share")}` })]
: []),
...(e.spec ? [el("div", { className: "m01-logic__muted", text: e.spec })] : []),
...(e.warn
? [
el("div", {
className: "m01-logic__tag m01lab__warn",
text: `${tx("Calc_UnitWarn")} · ${e.warn}`,
}),
]
: []),
],
}),
cellOf(e.unit),
@@ -10,7 +10,6 @@
import { createButton, el } from "@ui/ui_template_elements";
import {
fetchMaterials,
searchElements,
type ElementBrief,
type NamedFormula,
@@ -46,7 +45,18 @@ export interface PickCond {
export const condText = (cond: PickCond): string =>
["구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별"]
.map((k) => String(cond[k] ?? ""))
.join("|");
.join("|") + (cond["검색어"] ? `|${cond["검색어"]}` : "");
/** 재료 모달 검색 칸 글 — 검색어 첫 낱말의 괄호 앞(「합판(내수) 12t」 → 「합판」) */
export const headWord = (text: unknown): string =>
String(text ?? "")
.trim()
.split(/\s+/)[0]
.replace(/[((].*$/, "");
/** 고르기 줄 후보 찾기 글 — 검색어 → 이름 → 상세구분 → 구분 차례 첫 낱말 */
export const seedOf = (cond: PickCond): string =>
[cond["검색어"], cond.이름, cond.상세구분, cond.구분].map(headWord).find(Boolean) ?? "";
export interface ModalTarget {
title: string;
@@ -227,19 +237,12 @@ const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__mu
/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */
function candidateView(
cond: PickCond,
unit: string,
pickedRef: string,
onPicked: (item: ElementBrief | null) => void,
): HTMLElement {
const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } });
box.append(muted("…"));
void fetchMaterials({
sub: String(cond["구분"] ?? ""),
detail: String(cond["상세구분"] ?? ""),
spec: String(cond["규격"] ?? ""),
region: "",
unit,
})
void searchElements("재료", seedOf(cond))
.then((got) => {
onPicked(got.items.find((it) => it.ref === pickedRef) ?? null);
const rows = got.items.map((it) =>
@@ -301,12 +304,17 @@ export function openMaterialModal(target: ModalTarget): void {
const element = target.element ?? "";
const base = element.split(".")[0];
// 고르기 줄 = 서버가 조건 글을 열쇠로 준 줄(대표가 안 정해졌으면 없음)
const priced = cond ? target.prices[condText(cond)] : undefined;
const known = cond
? target.prices[condText(cond)]
? target.unitRef && priced?.ref !== target.unitRef
? undefined // 서버가 잡은 품목이 다르면 그 품목으로
: priced
: element
? (target.prices[element] ?? target.prices[base])
: undefined;
const priceRef = cond ? pickedOf(cond, target.values, known?.ref ?? "") : element;
const priceRef = cond
? target.unitRef || pickedOf(cond, target.values, known?.ref ?? "")
: element;
const logicKey = /^로직\((\w+)/.exec(element)?.[1] ?? "";
const sum = el("p", { className: "m01lab__sum" });
const setSum = (price: ElementBrief | null | undefined): void => {
@@ -366,7 +374,7 @@ export function openMaterialModal(target: ModalTarget): void {
rate,
...(cond
? [
candidateView(cond, target.unit ?? "", priceRef, (item) => {
candidateView(cond, priceRef, (item) => {
if (known || !priceRef) return;
const show = (brief: ElementBrief | null): void => {
rate.replaceChildren(
@@ -90,6 +90,8 @@ export interface CalcLine {
출처?: string;
/** 자재 단위가 줄 단위와 성격이 달라 환산 못 하고 셈 — 「줄 m · 자재 Ton」 */
단위경고?: string;
/** 재료 줄이 잡은 품목 — 키 · 이름 · 규격 · 단위 · 값 칸 */
품목?: { 키: string; 이름?: string; 규격?: string; 단위?: string; 값칸?: string };
}
export type CalcAnswer =
| {
@@ -21,6 +21,7 @@ import {
machineControl,
materialRows,
pickBody,
showItems,
} from "./M01_MasterData_UI_Logic_CalcPick";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
@@ -183,6 +184,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines);
out.replaceChildren(...answerView(answer, draft, (ctx.row.결과단위 ?? "").startsWith("원")));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
@@ -6,10 +6,10 @@
import { createButton, el } from "@ui/ui_template_elements";
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
import type { ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api";
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, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
interface Chosen {
ref: string;
@@ -33,7 +33,6 @@ export function pickBody(values: Record<string, string>): { 고름?: Record<stri
}
const nameOf = (row: Row): string => `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
const firstWord = (text: string): string => text.trim().split(/\s+/)[0] ?? "";
const MATERIAL_KEYS = /^MT\d{6}$/;
const MACHINE_CODE = /^\d{4}-\d{4}$/;
@@ -73,17 +72,18 @@ export function materialRows(
? (item.요소 as unknown as PickCond)
: null;
const brief = (cond ? prices[condText(cond)] : prices[item.요소]) ?? null;
const label = item.이름 ?? (brief?.이름 as string | undefined) ?? "";
const named = item.이름 && !/^[A-Z]{2}\d{6}/.test(item.이름) ? item.이름 : "";
const label = named || (brief?.이름 as string | undefined) || "";
const held = mine.get(i);
const nowRef = held?.ref ?? brief?.ref;
const nowName =
held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? "";
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
.map((w) => firstWord(String(w ?? "")))
.map(headWord)
.filter((w, k, all) => w && all.indexOf(w) === k);
const current = el("span", {
className: "m01-logic__muted",
text: `${nowName || tx("Mat_NoPrice")} `,
attrs: { "data-mat-idx": String(i), "data-ref": brief?.ref ?? "" },
});
const button = pickButton(tx("Pick_Item"), () =>
openPickTable({
@@ -91,7 +91,7 @@ export function materialRows(
title: `${tx("Pick_MatTitle")} · ${label}`,
seed: seeds[0] ?? "",
alt: seeds.slice(1),
isCurrent: (r) => String(r["키"]) === nowRef,
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)} `;
@@ -103,6 +103,22 @@ export function materialRows(
});
}
/** 계산 답의 `품목` 을 잡힌 품목 글로 — 고른 재료가 있는 줄은 그대로 */
export function showItems(
host: HTMLElement,
values: Record<string, string>,
lines: CalcLine[],
): void {
const mine = picksOf(values);
host.querySelectorAll<HTMLElement>("[data-mat-idx]").forEach((span) => {
const i = Number(span.dataset.matIdx);
const item = lines[i]?.품목;
if (!item || mine.has(i)) return;
span.dataset.ref = item.키;
span.textContent = `${`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키} `;
});
}
/** 기계 입력 칸 — 표 모달로 고르면 입력값(원문 분류번호 · 키 목록이면 키) */
const machines = new Map<string, Row | null>();
async function machineRow(code: string): Promise<Row | null> {