feat(737): 단가산출 상세 옵션 6 열 격자 · 합계 한 줄 바닥 고정 · 텍스트 수식 비목 상자 · 두 보기 같은 제목

- 옵션 칸 = 상자 폭 기준 1 → 2 → 3 → 4 → 6 열(calc_sheet optionGrid · container query) · 공통 옵션 따로 상자 걷고 모든 상자 맨 위 옵션 칸에 같이
- 맨 아래 노무비 · 재료비 · 경비 · 계 = 한 줄 4 칸 · 화면(모달) 바닥 sticky · 메인 칸 overflow clip(굴림 칸이면 안 붙음)
- 텍스트 수식 = 비목 구분 상자 · 상자 위 옵션 칸 · 줄마다 붙던 옵션 꼬리표 걷음
- 두 보기 상자 제목 「단가산출 상세」 · 「지금 입력」 꼬리표 걷음
- 신규 지킴 시험 test_737_option_grid_foot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 18:25:09 +09:00
co-authored by Claude Sonnet 5.5
parent 0ae48d0a89
commit 10984109b4
7 changed files with 249 additions and 68 deletions
@@ -3,13 +3,15 @@
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
* 714-3 — 줄이 쓰는 입력(서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
* 737-2 — 비목(노무비 · 재료비 · 경비)마다 구분 상자 · 맨 위 옵션 고르기(그 상자 줄이 쓰는 입력 · 서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각 + 어느 줄도 안 쓰는 공통 입력 · 6 → 4 → 3 → 2 → 1 열) →
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createCard } from "@ui/ui_template_card";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
import { optionGrid, varRow } from "../A00_Common/calc_sheet";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
@@ -118,43 +120,40 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
);
return;
}
const heads = el("div", {
className: "m01lab-fx__head",
children: [
el("strong", { text: tl("Formula_Left") }),
el("strong", { text: tl("Formula_Right") }),
],
});
const heads = (): HTMLElement =>
el("div", {
className: "m01lab-fx__head",
children: [
el("strong", { text: tl("Formula_Left") }),
el("strong", { text: tl("Formula_Right") }),
],
});
const rows: HTMLElement[] = [];
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
const vars = (line: TextLine): HTMLElement[] => {
const names = specs.map((x) => x.이름);
const seen = new Map(usedInputs(line, names));
// 서버 「쓰는입력」(714-1 · 조건 · 재료 고르기 · 중간값까지) 이 있으면 그것 · 없으면 조각에서
const used: [string, string][] = line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n)).map((n) => [n, seen.get(n) ?? ""])
: [...seen];
if (!used.length || !ctx.onInput) return [];
// 737-2 옵션 칸 = 비목 상자 맨 위 — 그 상자 줄이 쓰는 입력 + 어느 줄도 안 쓰는 입력(공통 · 737-3)
const names = specs.map((x) => x.이름);
const usedBy = (line: TextLine): string[] =>
line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n))
: usedInputs(line, names).map(([n]) => n);
const everyUsed = new Set(text.groups.flatMap((g) => g.줄.flatMap(usedBy)));
const common = names.filter((n) => !everyUsed.has(n));
const options = (g: (typeof text.groups)[number]): HTMLElement[] => {
const onInput = ctx.onInput;
return [
el("div", {
className: "m01lab-fx__vars",
children: used.map(([name, was]) =>
inputChip(
specs.find((x) => x.이름 === name)!,
ctx.values?.[name] || was,
(v) => onInput(name, v),
),
),
}),
];
if (!onInput) return [];
const mine = new Set([...g.줄.flatMap(usedBy), ...common]);
return optionGrid(
specs
.filter((x) => mine.has(x.이름))
.map((x) => varRow(x, ctx.values?.[x.이름] ?? "", (v) => onInput(x.이름, v))),
);
};
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
const light = (pair: string, on: boolean): void =>
rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on));
const blocks = text.groups.flatMap((g) => [
el("h4", { className: "m01lab__group m01lab-fx__wide", text: g.비목 }),
...g.줄.map((line, i) => {
// 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계
const boxes = text.groups.map((g) => {
const lines = g.줄.map((line, i) => {
const pair = String(line.짝 ?? `${g.비목}${i}`);
const row = el("div", {
className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`,
@@ -166,7 +165,6 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
children: calculated
? [
el("div", { text: `${line.이름} : ${line.글}` }),
...vars(line),
...(line.까닭
? [
el("div", {
@@ -189,20 +187,36 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
});
rows.push(row);
return row;
}),
});
const card = createCard({
title: g.비목,
pad: 12,
body: [
...options(g),
el("div", {
className: "m01lab-fx",
children: [
heads(),
...lines,
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.소계) },
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
}),
],
}),
],
});
card.classList.add("m01lab__ho-box");
card.dataset.fxBox = g.비목;
return card;
});
host.replaceChildren(
...boxes,
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.소계) },
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
}),
]);
blocks.unshift(heads);
blocks.push(
el("p", {
className: "m01lab__total m01lab-fx__wide",
className: "m01lab__total",
attrs: { "data-text-sum": String(text.계) },
text: `${tl("Total")} ${formatMoney(text.계)}`,
}),
);
host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks }));
}