feat(M01): 몫 줄 단가 = 단가 x 몫 비율 · 줄 모달 윗줄 요소조각 · 로직 알약이 그 로직을 엶 (PLAN 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 11:10:52 +09:00
co-authored by Claude Sonnet 5
parent cebd49980a
commit a523066de4
4 changed files with 69 additions and 13 deletions
@@ -34,6 +34,7 @@ import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterDa
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail";
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New";
@@ -441,5 +442,6 @@ export async function mountM01LogicLab(
} finally {
hideLoadingOverlay();
}
setLogicOpener((key) => void openKeyOn(key));
return { openKey: openKeyOn };
}
@@ -26,6 +26,7 @@ import {
openMaterialModal,
openNoteModal,
openRefModal,
openLogic,
openTableModal,
type PickCond,
} from "./M01_MasterData_UI_LogicLab_Modal";
@@ -47,7 +48,7 @@ function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
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.}`);
else openLogic(ref.);
};
return { pill: { label: piece.글, onClick: open }, ...dim };
});
@@ -93,7 +94,10 @@ interface Entry {
amount: number | null;
/** 덧줄이면 true */
extra: boolean;
open: () => void;
open: (self: Entry) => void;
/** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */
unitFrag?: Fragment[];
piecesFrag?: Fragment[];
}
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
@@ -194,7 +198,15 @@ const spread = (e: Entry, line?: CalcLine): Entry[] => {
...e,
group: OF_COST[k] ?? "other",
amount: v,
...(parts.length > 1 ? { share: k } : {}),
...(parts.length > 1
? {
share: k,
// 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게
...(line && line. && line. != null
? { price: formatMoney((line. * v) / line.) }
: {}),
}
: {}),
}));
};
@@ -217,8 +229,10 @@ function entries(ctx: DetailContext): Entry[] {
price: price === undefined || price === null ? "" : formatMoney(price),
amount: line ? line.금액 : null,
extra: false,
open: () =>
open: (self) =>
openMaterialModal({
unitFrag: self.unitFrag,
qtyFrag: self.piecesFrag,
title: item.이름 ?? (cond ? condLabel(cond) : item.),
expr: item.수량,
...(cond ? { cond, unit: item.단위 } : { element: item.요소 }),
@@ -242,8 +256,10 @@ function entries(ctx: DetailContext): Entry[] {
price: "",
amount: line ? line.금액 : null,
extra: true,
open: () =>
open: (self) =>
openMaterialModal({
unitFrag: self.unitFrag,
qtyFrag: self.piecesFrag,
title: extra.이름,
expr: extra.식,
prices: ctx.prices,
@@ -267,7 +283,9 @@ function attachPieces(all: Entry[], ctx: DetailContext): void {
if (lines.length !== mine.length) continue;
mine.forEach((e, i) => {
const pieces = lines[i].;
if (pieces?.length) e.qtyFrag = toFragments(pieces, ctx);
if (pieces?.length) e.qtyFrag = e.piecesFrag = toFragments(pieces, ctx);
const unit = lines[i].;
if (unit?.length) e.unitFrag = toFragments(unit, ctx);
});
}
}
@@ -300,8 +318,8 @@ function lineRow(e: Entry): HTMLElement {
cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"),
],
});
tr.addEventListener("click", e.open);
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open());
tr.addEventListener("click", () => e.open(e));
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e));
return tr;
}
@@ -19,8 +19,19 @@ import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note";
import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side";
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
/** 로직 알약 → 그 로직 열기 — 로직 화면이 자기 열기를 등록 · 열려 있는 모달은 다 닫음 */
let logicOpener: (key: string) => void = () => {};
export const setLogicOpener = (fn: (key: string) => void): void => {
logicOpener = fn;
};
export function openLogic(key: string): void {
document.querySelectorAll(".m01-logic__backdrop").forEach((b) => b.remove());
logicOpener(key);
}
/** 재료 고르기 조건(호표 줄 `요소` 가 글 대신 객체일 때) — 서버 `master_material.PICK_KEYS` 와 같은 칸 */
export interface PickCond {
구분?: string;
@@ -51,6 +62,10 @@ export interface ModalTarget {
middles: NamedFormula[];
/** 시험 계산 칸에 넣은 값 — 걸린 줄을 맞히는 데 씀 */
values: Record<string, string>;
/** 서버 `요소조각` — 단가 자리(요소 · 로직 부르기)를 조각으로 · 없으면 로직 키만 알약으로 */
unitFrag?: Fragment[];
/** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */
qtyFrag?: Fragment[];
}
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
@@ -289,18 +304,31 @@ export function openMaterialModal(target: ModalTarget): void {
? (target.prices[element] ?? target.prices[base])
: undefined;
const priceRef = cond ? pickedOf(cond, target.values, known?.ref ?? "") : element;
const logicRef = element.startsWith("로직(") ? element : "";
const logicKey = /^로직\((\w+)/.exec(element)?.[1] ?? "";
const sum = el("p", { className: "m01lab__sum" });
const setSum = (price: ElementBrief | null | undefined): void => {
const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.)} × ` : "";
sum.textContent = `${target.title} = ${unit}${explain(target.expr)}`;
if (target.qtyFrag) {
sum.replaceChildren(`${target.title} = ${unit}`, fragmentRow(target.qtyFrag));
} else sum.textContent = `${target.title} = ${unit}${explain(target.expr)}`;
};
setSum(known);
const rate = el("div", { className: "m01lab__modal-body" });
if (known && priceRef) {
rate.append(priceView(priceRef, known));
} else if (logicRef) {
rate.append(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }));
} else if (logicKey) {
rate.append(
el("p", {
children: [
`${tl("Modal_Logic")} · `,
fragmentRow(
target.unitFrag ?? [
{ pill: { label: logicKey, onClick: () => openLogic(logicKey) } },
],
),
],
}),
);
} else if (cond && priceRef) {
rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴
} else if (cond) {
@@ -335,7 +363,13 @@ export function openMaterialModal(target: ModalTarget): void {
className: "m01lab__raw",
children: [
el("summary", { text: tl("Modal_Qty") }),
el("pre", { className: "m01lab__expr", text: target.expr }),
el("pre", {
className: "m01lab__expr",
text: logicKey
? `${element}
${target.expr}`
: target.expr,
}),
],
}),
);
@@ -113,6 +113,8 @@ export interface TextLine {
이름글?: string;
/** 수량 식을 쪼갠 조각 — 알약(요소·표·로직) · 글자(값·기호·글) */
조각?: TextPiece[];
/** 단가 자리(요소 · 로직 부르기)를 쪼갠 조각 — 줄 모달 윗줄 */
요소조각?: TextPiece[];
/** 줄 번호(답 전체에서 하나씩) — 두 칸 줄 맞춤 · 밝히기 */
짝?: number;
금액: number;