- 새 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>
297 lines
10 KiB
TypeScript
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)],
|
|
}),
|
|
);
|
|
}
|