Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts
T
eomsangdonandClaude Opus 5.5 93b13e975f feat(790-3): 단가산출 시트 숫자 넓힘 화면 — 식 속 상수 · 표값 칸 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜
- 새 M02_MasterTemplete_Sangeun_SheetEdit.ts — 790-2 셈 답 시트.자리 를 칸으로: 식 속 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」) · 글 줄 더함 · 고침 · 지움(줄마다 [+ 글] · 근거 머리 [+ 근거 글]) · 까닭 칸(고친 줄 · 뺀 줄 · 더한 줄) · [M01 원래로] 줄마다 · 상태줄 전체 · 「M01 바뀜」 배지 · 짝잃음 띠
- Sheet_Model — 식 조각(서버 scan 숫자 · 표 찾기 차례 · 안 맞으면 칸 안 그림) · 산출서에 글 줄 · 되살린 더한줄 열쇠 밂(liftAddKeys)
- Sheet — 옛 [되돌림] 단추를 [M01 원래로]로 · 더한줄 빼면 까닭 · 글줄 열쇠 당김(dropAddKeys) · 문서 고침 = 캐시 → 서버 셈 · 자동저장 없음
- 더한줄 줄 사이 끼움은 SK 모델에 자리 칸이 없어 덩이 끝 그대로(글 줄만 자리 뒤)
- test_790_1 늘림(정적 단언 + 식 조각 · 글줄 산출서 · 열쇠 밂)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:34:02 +09:00

447 lines
16 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Sangeun_Sheet.ts
* M02 단가산출(SK) 줄 식 시트(PLAN 790-1 · E1 · 요구 650~652 · 요구서_652 1 장 4) — 상세 첫 탭 「시트」.
* 보기 고르기 = 시트 · 옛 보기 넷(단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — E7 까지 둠)
* 시트 = 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M02_Sangeun_Sheet_Model`) · 안 보기 전환 「시트 ↔ 산출서」
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import {
addTopNote,
formulaEditor,
lostBanner,
noteRows,
reasonCell,
removedReasons,
restoreAll,
spotTail,
type EditCtx,
} from "./M02_MasterTemplete_Sangeun_SheetEdit";
import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model";
import {
dropLine,
PART_OF,
restoreLine,
setLineCover,
type CoverRow,
type HoView,
type LineCover,
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
import {
diffText,
liftAddKeys,
num,
paperLines,
sheetItems,
spotsOf,
statusOf,
type SheetItem,
type SheetKind,
} from "./logic/M02_Sangeun_Sheet_Model";
import "./M02_MasterTemplete_Sangeun_Sheet.css";
const TEXT = {
Sheet: ["시트", "Sheet"],
Paper: ["산출서", "Statement"],
Mode: ["시트 보기", "Sheet view"],
Origin: ["M01 원래", "M01 original"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before edit"],
근거: ["근거", "Basis"],
옵션: ["옵션", "Options"],
상수: ["변수 · 계산", "Variables"],
자원: ["자원", "Resources"],
율: ["율", "Rates"],
소계: ["소계 · 합계", "Subtotals"],
Qty: ["품", "Qty"],
Price: ["단가", "Price"],
Rate: ["율 %", "Rate %"],
Drop: ["빼기", "Remove"],
AddRes: ["+ 자원 줄", "+ Resource"],
AddRate: ["+ 율 줄", "+ Rate line"],
Table: ["표", "Table"],
Hidden: ["조건 · 표 값", "Condition · table value"],
Wait: ["셈 기다리는 중", "Calculating…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** 줄 갈래 → 덩이(같은 머리 아래) */
const SECTION: Record<SheetKind, keyof typeof TEXT> = {
근거: "근거",
옵션: "옵션",
상수: "상수",
표: "상수",
계산: "상수",
자원: "자원",
율: "율",
소계: "소계",
합계: "소계",
};
export interface SheetDraw {
doc: SkDoc;
ans: SkAnswer | null;
bare: SkAnswer | null;
base: LogicRow | null;
text: TextAnswer | null;
rows: CoverRow[];
view: HoView | null;
values: Record<string, string>;
removed: { cid: string; 이름: string }[];
fail: string;
}
export interface SheetOptions {
edit: boolean;
opened: () => boolean;
touch: () => void;
onInput: (name: string, value: string) => void;
addLine: () => void;
addRate: () => void;
}
type View = "sheet" | "ho" | "est" | "calc" | "formula";
/** 보기 고르기(시트 + 옛 넷) · 시트 그릇 — `editor` = 옛 보기 그릇(시트일 때 숨김) */
export function createSangeunSheet(
editor: HTMLElement,
opt: SheetOptions,
): { picker: HTMLElement; host: HTMLElement; draw: (d: SheetDraw) => void; saved: () => void } {
const host = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } });
const status = el("div", { className: "m02-sks__status", attrs: { "data-sk-status": "" } });
const body = el("div", { className: "m02-sks__body" });
let mode: "sheet" | "paper" = "sheet";
let last: SheetDraw | null = null;
let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
let ctx: EditCtx | null = null; // 790-3 숫자 넓힘 칸 — 그릴 때마다 새로
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
const show = (v: View): void => {
host.hidden = v !== "sheet";
editor.hidden = v === "sheet";
if (v !== "sheet") editor.dataset.view = v;
};
const picker = createSegment({
options: [
{ value: "sheet", label: t("Sheet") },
{ 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: "sheet",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => show(v as View),
});
picker.root.dataset.skViews = "";
const inner = createSegment({
options: [
{ value: "sheet", label: t("Sheet") },
{ value: "paper", label: t("Paper") },
],
value: mode,
size: 28,
ariaLabel: t("Mode"),
onChange: (v) => {
mode = v as typeof mode;
if (last) draw(last);
},
});
inner.root.dataset.skMode = "";
host.append(createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }), body);
show("sheet");
function drawStatus(d: SheetDraw): void {
const s = statusOf(d.ans, d.bare, before);
const part = (key: string, label: string, v: number | null, diff = ""): HTMLElement =>
el("span", {
className: "m02-sks__amount",
attrs: { "data-sk-amount": key },
children: [
createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14),
...(diff ? [createBadge(diff, diff.startsWith("+") ? "warning" : "info")] : []),
],
});
status.replaceChildren(
part("origin", t("Origin"), s.원래),
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
...(ctx ? restoreAll(ctx) : []),
);
}
/** 글 칸 — 바꾸면(Enter · 떠남) `onSet(글)` · 이름표 없으면 칸만 */
const textCell = (
key: string,
label: string,
v: string,
onSet: (raw: string) => void,
): HTMLElement => {
const box = createInputField({ value: v, compact: true, size: 24 });
box.input.setAttribute("aria-label", label);
box.input.dataset.sheetCell = key;
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
if (!label) return box.root;
return el("span", {
className: "m02-sks__cell",
children: [createText(label, "muted"), box.root],
});
};
/** 숫자 칸 — 빈 칸 = null(그 칸 풂) · 못 읽으면 되돌림 */
const numCell = (
key: string,
label: string,
v: number | null,
onSet: (x: number | null) => void,
): HTMLElement => {
const cell = textCell(key, label, v == null ? "" : String(Number(v.toFixed(6))), (raw) => {
const x = raw ? Number(raw.replaceAll(",", "")) : null;
if (x !== null && !Number.isFinite(x))
return void (input.value = v == null ? "" : String(Number(v.toFixed(6))));
onSet(x);
});
const input = cell.querySelector("input")!;
input.inputMode = "decimal";
return cell;
};
/** 자원 · 율 줄 고침 — 덮개 한 칸 바꿈(빈 칸 = 그 칸 풂) → 상세 다시 셈 */
function setCell(d: SheetDraw, r: CoverRow, key: keyof LineCover, x: number | null): void {
const i = d.view?.cids.indexOf(r.cid) ?? -1;
const cur: LineCover = { ...(i >= 0 ? d.view?.covers[i] : undefined) };
if (x == null) delete cur[key];
else cur[key] = x;
setLineCover(d.doc, r, Object.keys(cur).length ? cur : null, opt.opened());
opt.touch();
}
function editLine(d: SheetDraw, r: CoverRow): HTMLElement[] {
if (!opt.edit) return [];
const parts = Object.keys(r.parts);
const part = PART_OF[parts[0] ?? ""];
const cells = [
numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) => setCell(d, r, "qty", x)),
...(r.priceEdit && part && parts.length === 1
? [numCell(`${r.cid}|price`, t("Price"), r.단가, (x) => setCell(d, r, part, x))]
: []),
];
return [el("div", { className: "m02-sks__edit", children: cells })];
}
function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement {
const r = it.row;
const kids: HTMLElement[] = it.비목
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
: [];
if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null));
if (r && opt.edit) {
const drop = createButton({
label: t("Drop"),
variant: "danger-ghost",
size: 22,
onClick: () => {
dropLine(d.doc, r);
if (r.added !== null && !r.sub) apply(d, dropAddKeys(d.doc as ConstDoc, r.added));
else opt.touch();
},
});
drop.dataset.sheetDrop = r.cid;
kids.push(drop);
}
return el("span", { children: kids });
}
function optionCell(d: SheetDraw, it: SheetItem): HTMLElement {
const spec = it.input!;
const value = d.values[spec.이름] ?? "";
if (!opt.edit) return createCalcFormula(value || "—");
if (spec.고르기?.length) {
const sel = createSelectField({
options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({
value: o,
text: o,
})),
value,
compact: true,
size: 24,
ariaLabel: spec.이름,
onChange: (v) => opt.onInput(spec.이름, v),
});
sel.select.dataset.sheetCell = `opt|${spec.이름}`;
return sel.root;
}
return textCell(`opt|${spec.이름}`, "", value, (v) => opt.onInput(spec.이름, v));
}
const valueOf = (it: SheetItem): HTMLElement => {
const v = it.값;
const text =
typeof v === "number"
? it.kind === "자원" || it.kind === "율"
? formatLineMoney(v)
: it.kind === "소계" || it.kind === "합계"
? formatMoney(v)
: num(v)
: it.kind === "옵션"
? (it.input?.단위 ?? "")
: (v ?? (it.kind === "근거" ? "" : "—"));
const key = it.kind === "소계" || it.kind === "합계";
return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) });
};
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
const nums = ctx && it.자리 ? formulaEditor(ctx, it.자리) : null;
const shown = it.식 || (it.kind === "계산" ? t("Hidden") : "");
const middle =
it.kind === "옵션"
? optionCell(d, it)
: el("div", {
className: "m02-sks__mid",
children: [
...(it.row || !nums ? [createCalcFormula(shown)] : []),
...(nums ? [nums] : []),
...(it.row ? editLine(d, it.row) : []),
...(ctx ? reasonCell(ctx, it.자리, it.changed) : []),
],
});
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it, !!nums));
row.dataset.sheetKind = it.kind;
if (it.row) row.dataset.sheetCid = it.row.cid;
if (it.자리) row.dataset.sheetSpot = it.자리;
if (it.changed) row.classList.add("is-changed");
const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : [];
return [row, ...notes];
}
/** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */
function apply(d: SheetDraw, next: ConstDoc): void {
const doc = d.doc as Record<string, unknown>;
for (const k of Object.keys(doc)) if (!(k in next)) delete doc[k];
Object.assign(doc, next);
opt.touch();
}
const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement =>
el("div", {
className: "m02-sks__head",
attrs: { "data-sheet-section": key },
children: [createText(t(key), "muted", 14), ...extra],
});
function drawSheet(d: SheetDraw): HTMLElement[] {
const items = sheetItems({
base: d.base,
inputs: d.base?.입력 ?? [],
values: d.values,
ans: d.ans,
rows: d.rows,
text: d.text,
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
});
const spots = new Set(items.map((it) => it.자리).filter(Boolean));
const out: HTMLElement[] = ctx ? lostBanner(ctx) : [];
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
opt.edit
? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })]
: [];
const order: (keyof typeof TEXT)[] = ["근거", "옵션", "상수", "자원", "율", "소계"];
for (const sec of order) {
const mine = items.filter((it) => SECTION[it.kind] === sec);
const tools =
sec === "자원"
? add("AddRes", opt.addLine)
: sec === "율"
? add("AddRate", opt.addRate)
: sec === "근거" && ctx
? addTopNote(ctx)
: [];
if (!mine.length && !tools.length) continue;
out.push(head(sec, tools));
if (sec === "자원")
out.push(
...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)),
...(ctx ? removedReasons(ctx, d.removed) : []),
);
for (const it of mine) out.push(...rowOf(d, it));
if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리)));
}
return out;
}
/** 뺀 줄 되살림 — 뺀 더한줄(`gone:N`)이면 까닭 · 글줄 열쇠를 그 차례부터 한 칸 밂 */
function revive(d: SheetDraw, cid: string): void {
const gone = /^gone:(\d+)$/.exec(cid);
const at = gone ? d.doc.뺀더한줄?.[Number(gone[1])]?.차례 : undefined;
restoreLine(d.doc, cid);
if (at != null) apply(d, liftAddKeys(d.doc as ConstDoc, at));
else opt.touch();
}
function drawPaper(d: SheetDraw): HTMLElement[] {
return paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄).map((p) => {
const line = el("p", {
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`,
attrs: { "data-paper": p.kind },
text: p.글,
});
return line;
});
}
function draw(d: SheetDraw): void {
last = d;
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
ctx = {
doc: d.doc as ConstDoc,
edit: opt.edit,
spots: spotsOf(d.ans),
apply: (next) => apply(d, next),
focus: (key) => void (focusNext = key),
};
drawStatus(d);
const active = document.activeElement as HTMLElement | null;
const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : "");
focusNext = "";
body.dataset.mode = mode;
body.replaceChildren(
...(d.ans
? mode === "sheet"
? drawSheet(d)
: drawPaper(d)
: [createText(d.fail || t("Wait"), d.fail ? "error" : "muted")]),
);
if (keep) body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)?.focus();
}
return {
picker: picker.root,
host,
draw,
saved: () => {
before = last?.ans?.기준?.계 ?? null; // 저장 = 고치기 전 새로(상태줄만 다시)
if (last) drawStatus(last);
},
};
}