feat(M01): 로직 화면 — 자동값 채움 · 서버 조각 알약 · 재료 고르기 모달 · 돈 모양 한 벌 (PLAN 3-4 화면)
- 시험 계산: 로직을 열 때 GET /logic/auto 값을 빈 입력에 채우고 「견본」 딱지(사용자가 고치면 빠짐) 뒤 바로 계산 · 텍스트 수식까지. 서버 길이 없으면 안 채움 - 단가산출 상세 수량 칸: 화면 쪽 식 풀이(qtyFragments) 걷고 /text 줄의 조각으로 그림 — 알약 글 = 조각 글 · 깊이 있는 조각은 흐리게 · 묶음별 줄 수가 안 맞으면 식 글자 그대로 - 표 알약 모달: 참조.행·열에 색(행을 못 받으면 열만) - 재료 고르기 줄(요소가 객체): 모달이 객체를 받게 — 잡힌 품목 단가 + 후보 목록 - 돈 모양 한 벌(Logic_Money.ts): 상세·모달·텍스트 수식 소계와 계·시험 계산 결과·조합 미리 보기 정수 · 수량·비율은 그대로 - 서버 파일은 안 만짐 검증 — typecheck 통과 · GF000160 ORCA: 알약 7 서로 다름 · 계 141,882 · 재료 줄 모달 · GF000219·GF000001 회귀 없음. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY2vdBQkHjdih9GgCsp9Fd
This commit is contained in:
@@ -14,57 +14,43 @@ import type {
|
||||
LogicRow,
|
||||
NamedFormula,
|
||||
TextAnswer,
|
||||
TextPiece,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { openMaterialModal, openTableModal } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import {
|
||||
condText,
|
||||
openMaterialModal,
|
||||
openNoteModal,
|
||||
openRefModal,
|
||||
openTableModal,
|
||||
type PickCond,
|
||||
} from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
|
||||
/** 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만. 수량·비율은 `formatNumber` 그대로 둠. */
|
||||
const formatMoney = (value: unknown): string =>
|
||||
typeof value === "number"
|
||||
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
|
||||
: formatNumber(value);
|
||||
|
||||
/** 조각 계약(`ref/_분석_알약_상세줄.md` ㉮ 표) — 수량 식 안 찾기(...) 표 호출만 알약,
|
||||
* 그 밖(숫자·연산기호·중간값·설계입력 이름)은 글자. 서버가 조각 목록을 아직 안 주므로
|
||||
* 여기서 임시로 쪼갬(`Logic_Note.ts` 찾기() 정규식과 같은 규칙) — 서버가 붙으면 이 파싱을 걷어냄. */
|
||||
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
|
||||
|
||||
function splitFindConds(raw: string): [string, string][] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p): [string, string] => {
|
||||
const i = p.indexOf("=");
|
||||
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
|
||||
});
|
||||
}
|
||||
|
||||
function qtyFragments(expr: string, values: Record<string, string>): Fragment[] {
|
||||
const out: Fragment[] = [];
|
||||
let last = 0;
|
||||
for (const m of expr.matchAll(FIND_RE)) {
|
||||
const at = m.index ?? 0;
|
||||
if (at > last) out.push({ text: expr.slice(last, at) });
|
||||
const table = m[1];
|
||||
const label = `${tl("Pill_Table")} ${table}`;
|
||||
out.push({
|
||||
pill: {
|
||||
label,
|
||||
onClick: () => openTableModal({ title: label, table, conds: splitFindConds(m[2]), values }),
|
||||
},
|
||||
});
|
||||
last = at + m[0].length;
|
||||
}
|
||||
if (last < expr.length) out.push({ text: expr.slice(last) });
|
||||
return out.length ? out : [{ text: expr }];
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
|
||||
return pieces.map((piece): Fragment => {
|
||||
const dim = piece.깊이 ? { dim: true } : {};
|
||||
if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직")
|
||||
return { text: piece.글, ...dim };
|
||||
const ref = piece.참조;
|
||||
const open = (): void => {
|
||||
if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick"));
|
||||
if (piece.kind === "표")
|
||||
openTableModal({ title: piece.글, table: ref.키, row: ref.행, col: ref.열 });
|
||||
else if (piece.kind === "요소")
|
||||
openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices });
|
||||
else openNoteModal(piece.글, `${tl("Modal_Logic")} · ${ref.키}`);
|
||||
};
|
||||
return { pill: { label: piece.글, onClick: open }, ...dim };
|
||||
});
|
||||
}
|
||||
|
||||
export interface DetailContext {
|
||||
@@ -108,6 +94,12 @@ interface Entry {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
|
||||
const condOf = (item: HoLine): PickCond | null =>
|
||||
typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null;
|
||||
const condLabel = (cond: PickCond): string =>
|
||||
[cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · ");
|
||||
|
||||
const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey);
|
||||
|
||||
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
@@ -204,24 +196,25 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
const ho = ctx.row.호표 ?? [];
|
||||
const out: Entry[] = ho.flatMap((item, i) => {
|
||||
const line = ctx.lines?.[i];
|
||||
const brief = ctx.prices[item.요소];
|
||||
const cond = condOf(item);
|
||||
const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소];
|
||||
const price = line ? line.단가 : brief?.값;
|
||||
const entry: Entry = {
|
||||
group: groupOfHo(item),
|
||||
name: item.이름 ?? item.요소,
|
||||
name: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
||||
spec: item.규격 ?? brief?.규격 ?? "",
|
||||
unit: item.단위 ?? "",
|
||||
qty: item.수량,
|
||||
qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량),
|
||||
qtyFrag: qtyFragments(item.수량, ctx.values),
|
||||
qtyFrag: [{ text: item.수량 }],
|
||||
price: price === undefined || price === null ? "" : formatMoney(price),
|
||||
amount: line ? line.금액 : null,
|
||||
extra: false,
|
||||
open: () =>
|
||||
openMaterialModal({
|
||||
title: item.이름 ?? item.요소,
|
||||
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
||||
expr: item.수량,
|
||||
element: item.요소,
|
||||
...(cond ? { cond, unit: item.단위 } : { element: item.요소 }),
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
@@ -238,7 +231,7 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
unit: "",
|
||||
qty: extra.식,
|
||||
qtyTag: tl("Extra"),
|
||||
qtyFrag: qtyFragments(extra.식, ctx.values),
|
||||
qtyFrag: [{ text: extra.식 }],
|
||||
price: "",
|
||||
amount: line ? line.금액 : null,
|
||||
extra: true,
|
||||
@@ -253,9 +246,25 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
};
|
||||
out.push(...spread(entry, line));
|
||||
});
|
||||
attachPieces(out, ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 서버 `/text` 줄의 조각을 묶음별로 차례대로 이음 — 줄 수가 서로 안 맞으면(계산 전 로직 부르는 줄 등)
|
||||
* 그 묶음은 식 글자 그대로 둠(화면에서 식을 다시 풀지 않음) */
|
||||
function attachPieces(all: Entry[], ctx: DetailContext): void {
|
||||
if (!ctx.text?.ok) return;
|
||||
for (const [cost, group] of Object.entries(OF_COST)) {
|
||||
const lines = ctx.text.groups.find((g) => g.비목 === cost)?.줄 ?? [];
|
||||
const mine = all.filter((e) => e.group === group);
|
||||
if (lines.length !== mine.length) continue;
|
||||
mine.forEach((e, i) => {
|
||||
const pieces = lines[i].조각;
|
||||
if (pieces?.length) e.qtyFrag = toFragments(pieces, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
|
||||
function lineRow(e: Entry): HTMLElement {
|
||||
|
||||
Reference in New Issue
Block a user