Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts
T
eomsangdonandClaude Opus 5.5 09a67ce91f feat(792): 단가산출 시트 식 칸 화면 — 줄 식 고침 · 칠 때마다 검사 · 빨간 칸 까닭 · 변수 줄
- 새 M02_MasterTemplete_Sangeun_SheetFormula.ts — 줄마다 [식 고침] → 이름 꼴 식 칸 · 칠 때마다 formula-check(옛 요청 끊기) · 틀리면 빨간 칸 + 까닭 · 바꿀 때 엔진 식을 식덮개로(같은 자리 상수 · 표값 · 품 덮개 걷음) · 빈 칸 · M01 식 = 풂 · 더한줄 수량식(율 · 로직 줄 뺌)
- 변수 줄 — [+ 변수 줄] · 이름 · 식 · 값 · 빼기(Formula_Model addVar · editVar · dropVar) · 자리 뒤에 그림
- Sheet — 식 덮개 줄은 품 칸 안 둠 · 숫자 칸 대신 식 칸 · [전체 M01 원래로]에 식 덮개 셈
- Detail — 시트 옵션에 층(scope)만(줄 수 그대로)
- test_790_1 — 식 칸 정적 단언

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

297 lines
10 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetEdit.ts
* 단가산출(SK) 시트 숫자 넓힘 칸(PLAN 790-3 · E2 · _분석_650-H3 1 · 2 장) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」)
* 글 줄(근거 글 · 셈 없음 · 산출서에 찍힘) · 까닭 칸 · [M01 원래로] · 「M01 바뀜」 · 짝잃음 띠
* 문서 고치기 = `logic/M02_Sangeun_Const_Model`(790-2 짝) · 금액은 늘 서버 셈(상세 touch)
* 부품 = 입력 · 단추 · 배지 · 띠 알림 · calc_row — 날 단추 · 입력 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
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 } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import {
addText,
coverKinds,
dropText,
editText,
restoreM01,
setConst,
setReason,
setTable,
type ConstDoc,
} from "./logic/M02_Sangeun_Const_Model";
import {
formulaParts,
type NoteLine,
type SheetSpots,
type SpotInfo,
} from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Num: ["식 속 숫자", "Constant"],
TableVal: ["표 값", "Table value"],
Cut: ["표 연결 끊김", "Table link cut"],
Changed: ["M01 바뀜", "M01 changed"],
Restore: ["M01 원래로", "Reset to M01"],
RestoreAll: ["전체 M01 원래로", "Reset all to M01"],
Reason: ["까닭", "Reason"],
ReasonHint: ["고친 까닭(짧게)", "Why changed (short)"],
Note: ["글", "Note"],
NoteHint: ["근거 글", "Basis note"],
AddNote: ["+ 글", "+ Note"],
AddTop: ["+ 근거 글", "+ Basis note"],
DropNote: ["글 지움", "Delete note"],
Lost: ["짝 잃은 고침 — M01 이 바뀌어 붙일 자리가 없음", "Orphan edits — M01 changed"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface EditCtx {
doc: ConstDoc;
edit: boolean;
spots: SheetSpots | null;
/** 새 문서로 바꿔 끼움(같은 객체 유지) → 다시 셈 */
apply: (next: ConstDoc) => void;
/** 다음 그림 뒤 이 칸에 초점 */
focus: (key: string) => void;
}
const strip = (v: number | null | undefined): string =>
v == null ? "" : String(Number(Number(v).toFixed(6)));
/** 작은 칸 — 바꾸면(Enter · 떠남) `onSet(글)` */
function smallCell(
key: string,
label: string,
v: string,
hint: string,
onSet: (raw: string) => void,
): HTMLElement {
const box = createInputField({ value: v, compact: true, size: 24, placeholder: hint });
box.input.setAttribute("aria-label", label);
box.input.dataset.sheetCell = key;
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
return box.root;
}
/** 숫자 칸 — 빈 칸 · 원값과 같음 = null(덮개 풂) · 못 읽으면 되돌림 */
function numBox(
key: string,
label: string,
v: number | null,
was: number | null,
cover: boolean,
onSet: (x: number | null) => void,
): HTMLElement {
const root = smallCell(key, label, strip(v), strip(was), (raw) => {
const x = raw ? Number(raw.replaceAll(",", "")) : null;
if (x !== null && !Number.isFinite(x)) return void (input.value = strip(v));
onSet(x === null || (was != null && x === Number(was)) ? null : x);
});
const input = root.querySelector("input")!;
input.inputMode = "decimal";
root.classList.add("m02-sks__num");
if (cover) root.dataset.cover = "";
return root;
}
/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */
export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null {
const info = c.spots?.자리[spot];
const parts = c.edit && !info?.식덮개 ? formulaParts(info) : null;
if (!info || !parts) return null;
const nums = c.doc.상수덮개?.[spot] ?? {};
const finds = c.doc.표값덮개?.[spot] ?? {};
const kids = parts.map((p): HTMLElement => {
if (p.t === "글") return el("span", { className: "m02-sks__frag", text: p.글 });
if (p.t === "수") {
const one = info.상수![p.n];
const mine = nums[String(p.n)];
return numBox(
`${spot}|n${p.n}`,
`${t("Num")} ${p.n + 1}`,
mine ?? one.값,
one.원값,
mine != null,
(x) => c.apply(setConst(c.doc, spot, p.n, x)),
);
}
const one = info.표값!.find((x) => x.n === p.n);
const was = typeof one?.원값 === "number" ? one.원값 : null;
const mine = finds[String(p.n)];
const shown = mine ?? (typeof one?.값 === "number" ? one.값 : null);
return el("span", {
className: "m02-sks__find",
children: [
numBox(`${spot}|t${p.n}`, `${t("TableVal")} ${p.n + 1}`, shown, was, mine != null, (x) =>
c.apply(setTable(c.doc, spot, p.n, x)),
),
...(p.표.length ? [createBadge(p.표.join(" "), "info")] : []),
...(mine != null ? [createBadge(t("Cut"), "warning")] : []),
],
});
});
const box = el("div", {
className: "m02-sks__formula",
attrs: { "data-sheet-formula": spot },
children: kids,
});
return box;
}
/** 꼬리 표지 · 단추 — M01 바뀜 · 표 연결 끊김 · [M01 원래로] · [+ 글] */
export function spotTail(c: EditCtx, spot: string, added: boolean): HTMLElement[] {
if (!spot) return [];
const info: SpotInfo | undefined = c.spots?.자리[spot];
const kinds = coverKinds(c.doc, spot);
const out: HTMLElement[] = [];
if (info?.M01바뀜) out.push(createBadge(t("Changed"), "danger"));
if (!c.edit) return out;
if (!added && kinds.length) {
const back = createButton({
label: t("Restore"),
variant: "ghost",
size: 22,
onClick: () => c.apply(restoreM01(c.doc, spot)),
});
back.dataset.sheetRestore = spot;
out.push(back);
}
const note = createButton({
label: t("AddNote"),
variant: "ghost",
size: 22,
onClick: () => addNote(c, spot),
});
note.dataset.sheetAddNote = spot;
out.push(note);
return out;
}
/** 까닭 칸 — 고친 자리(덮개 · 새 덮개 · 뺀줄 · 더한줄)만 */
export function reasonCell(c: EditCtx, key: string, need: boolean): HTMLElement[] {
const v = c.doc.까닭?.[key] ?? "";
if (!key || (!need && !v)) return [];
if (!c.edit) return [createCalcFormula(`${t("Reason")} · ${v}`)];
return [
el("span", {
className: "m02-sks__cell m02-sks__reason",
children: [
smallCell(`${key}|why`, t("Reason"), v, t("ReasonHint"), (x) =>
c.apply(setReason(c.doc, key, x)),
),
],
}),
];
}
export const isCovered = (doc: ConstDoc, spot: string): boolean =>
coverKinds(doc, spot).some((k) => k === "상수" || k === "표값");
function addNote(c: EditCtx, spot: string): void {
const lines = c.doc.글줄 ?? [];
let at = lines.length; // 같은 자리 글 줄 뒤
for (let i = lines.length - 1; i >= 0; i--)
if (lines[i].자리 === spot) {
at = i + 1;
break;
}
if (spot === "" && !lines.some((x) => x.자리 === "")) at = 0;
c.focus(`note|${at}`);
c.apply(addText(c.doc, spot, "", at));
}
/** 자리 뒤 글 줄들 — 편집 = 글 칸 · 지움 */
export function noteRows(c: EditCtx, pick: (x: NoteLine) => boolean): HTMLElement[] {
return (c.doc.글줄 ?? []).flatMap((x, i) => {
if (!pick(x)) return [];
const middle = c.edit
? smallCell(`note|${i}`, t("Note"), x.글, t("NoteHint"), (v) =>
c.apply(editText(c.doc, i, v)),
)
: createCalcFormula(x.글);
const tail = c.edit
? [
createButton({
label: t("DropNote"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropText(c.doc, i)),
}),
]
: [];
const row = createCalcSheetRow(t("Note"), middle, el("span"), el("span", { children: tail }));
row.dataset.sheetKind = "글";
row.classList.add("m02-sks__note");
return [row];
});
}
export const addTopNote = (c: EditCtx): HTMLElement[] =>
c.edit
? [
createButton({
label: t("AddTop"),
variant: "ghost",
size: 22,
onClick: () => addNote(c, ""),
}),
]
: [];
/** 상태줄 [전체 M01 원래로] — 덮개 · 뺀줄 · 상수 · 표값 덮개가 있을 때만 */
export function restoreAll(c: EditCtx): HTMLElement[] {
const d = c.doc;
const any =
Object.keys(d.덮개 ?? {}).length +
(d.뺀줄 ?? []).length +
Object.keys(d.상수덮개 ?? {}).length +
Object.keys(d.표값덮개 ?? {}).length +
Object.keys((d.식덮개 as object | undefined) ?? {}).length;
if (!c.edit || !any) return [];
const b = createButton({
label: t("RestoreAll"),
variant: "ghost",
size: 22,
onClick: () => c.apply(restoreM01(c.doc)),
});
b.dataset.sheetRestore = "*";
return [b];
}
/** 짝 잃은 새 덮개 띠(790-2 `시트.짝잃음`) */
export function lostBanner(c: EditCtx): HTMLElement[] {
const gone = c.spots?.짝잃음 ?? [];
return gone.length
? [
createBanner({
tone: "warning",
text: t("Lost"),
details: gone.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
}),
]
: [];
}
/** 뺀 바탕 줄 까닭 칸(뺀 줄 띠 아래) */
export function removedReasons(
c: EditCtx,
removed: { cid: string; 이름: string }[],
): HTMLElement[] {
if (!c.edit) return [];
return removed
.filter((x) => (c.doc.뺀줄 ?? []).includes(x.cid))
.map((x) =>
el("div", {
className: "m02-sks__cell m02-sks__reason",
attrs: { "data-sheet-removed": x.cid },
children: [createText(x.이름, "muted"), ...reasonCell(c, x.cid, true)],
}),
);
}