- 새 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>
447 lines
16 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|