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>
This commit is contained in:
@@ -84,3 +84,43 @@
|
||||
margin-top: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 790-3 숫자 넓힘 — 식 속 숫자 · 표 값 칸 · 까닭 · 글 줄 */
|
||||
.m02-sks__formula,
|
||||
.m02-sks__find {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-sks__frag {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-sks__num {
|
||||
width: 6.5em;
|
||||
}
|
||||
|
||||
.m02-sks__num[data-cover] {
|
||||
outline: 2px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-sks__reason .ui-field {
|
||||
width: min(24em, 100%);
|
||||
}
|
||||
|
||||
.m02-sks__note .ui-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-sks__paper--note {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
|
||||
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
|
||||
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
|
||||
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
|
||||
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -22,6 +23,18 @@ 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,
|
||||
@@ -35,9 +48,11 @@ import {
|
||||
} from "./logic/M02_Sangeun_Cover_Model";
|
||||
import {
|
||||
diffText,
|
||||
liftAddKeys,
|
||||
num,
|
||||
paperLines,
|
||||
sheetItems,
|
||||
spotsOf,
|
||||
statusOf,
|
||||
type SheetItem,
|
||||
type SheetKind,
|
||||
@@ -61,7 +76,6 @@ const TEXT = {
|
||||
Price: ["단가", "Price"],
|
||||
Rate: ["율 %", "Rate %"],
|
||||
Drop: ["빼기", "Remove"],
|
||||
Undo: ["되돌림", "Revert"],
|
||||
AddRes: ["+ 자원 줄", "+ Resource"],
|
||||
AddRate: ["+ 율 줄", "+ Rate line"],
|
||||
Table: ["표", "Table"],
|
||||
@@ -118,6 +132,8 @@ export function createSangeunSheet(
|
||||
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";
|
||||
@@ -169,6 +185,7 @@ export function createSangeunSheet(
|
||||
part("origin", t("Origin"), s.원래),
|
||||
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
|
||||
part("before", t("Before"), s.전, diffText(s.지금, s.전)),
|
||||
...(ctx ? restoreAll(ctx) : []),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,27 +248,23 @@ export function createSangeunSheet(
|
||||
return [el("div", { className: "m02-sks__edit", children: cells })];
|
||||
}
|
||||
|
||||
function tailOf(d: SheetDraw, it: SheetItem): HTMLElement {
|
||||
function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement {
|
||||
const r = it.row;
|
||||
const kids: HTMLElement[] = it.비목
|
||||
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
|
||||
: [];
|
||||
for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
|
||||
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) {
|
||||
if (r.added === null && (r.covered.qty || r.covered.price))
|
||||
kids.push(
|
||||
createButton({
|
||||
label: t("Undo"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => (setLineCover(d.doc, r, null, opt.opened()), opt.touch()),
|
||||
}),
|
||||
);
|
||||
const drop = createButton({
|
||||
label: t("Drop"),
|
||||
variant: "danger-ghost",
|
||||
size: 22,
|
||||
onClick: () => (dropLine(d.doc, r), opt.touch()),
|
||||
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);
|
||||
@@ -298,21 +311,35 @@ export function createSangeunSheet(
|
||||
};
|
||||
|
||||
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: [
|
||||
createCalcFormula(it.식 || (it.kind === "계산" ? t("Hidden") : "")),
|
||||
...(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));
|
||||
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");
|
||||
return [row];
|
||||
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 =>
|
||||
@@ -330,8 +357,10 @@ export function createSangeunSheet(
|
||||
ans: d.ans,
|
||||
rows: d.rows,
|
||||
text: d.text,
|
||||
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
|
||||
});
|
||||
const out: HTMLElement[] = [];
|
||||
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 })]
|
||||
@@ -344,22 +373,35 @@ export function createSangeunSheet(
|
||||
? 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) => (restoreLine(d.doc, cid), opt.touch())),
|
||||
...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).map((p) => {
|
||||
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" : "sum"}`,
|
||||
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`,
|
||||
attrs: { "data-paper": p.kind },
|
||||
text: p.글,
|
||||
});
|
||||
@@ -370,9 +412,17 @@ export function createSangeunSheet(
|
||||
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 = active && host.contains(active) ? active.dataset.sheetCell : undefined;
|
||||
const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : "");
|
||||
focusNext = "";
|
||||
body.dataset.mode = mode;
|
||||
body.replaceChildren(
|
||||
...(d.ans
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/* =============================================================================
|
||||
* 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 ? 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;
|
||||
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)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export interface SheetItem {
|
||||
input?: LogicInput;
|
||||
/** M01 과 다름(덮개 · 더한줄) */
|
||||
changed: boolean;
|
||||
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
|
||||
자리: string;
|
||||
}
|
||||
|
||||
export interface SheetBase {
|
||||
@@ -89,10 +91,12 @@ export function sheetItems(o: {
|
||||
ans: SkAnswer | null;
|
||||
rows: CoverRow[];
|
||||
text: TextAnswer | null;
|
||||
/** 790-3 자리에 새 덮개(상수 · 표값 · 뺌 …)가 있나 — 캐시 문서 기준 */
|
||||
covered?: (spot: string) => boolean;
|
||||
}): SheetItem[] {
|
||||
const out: SheetItem[] = [];
|
||||
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
|
||||
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, ...x });
|
||||
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, 자리: "", ...x });
|
||||
const b = o.base;
|
||||
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
|
||||
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
|
||||
@@ -107,9 +111,11 @@ export function sheetItems(o: {
|
||||
const raw = String(f.식 ?? "").trim();
|
||||
const v = mid[f.이름];
|
||||
const 값 = typeof v === "number" || typeof v === "string" ? v : null;
|
||||
if (NUM_RE.test(raw)) item({ kind: "상수", 이름: f.이름, 값: 값 ?? Number(raw) });
|
||||
else if (LOOKUP_RE.test(raw)) item({ kind: "표", 이름: f.이름, 값, 표: tablesOf(raw) });
|
||||
else item({ kind: "계산", 이름: f.이름, 식: readable(raw, aliases), 값, 표: tablesOf(raw) });
|
||||
const 자리 = `mid:${f.이름}`;
|
||||
const one = { 이름: f.이름, 값, 자리, changed: !!o.covered?.(자리) };
|
||||
if (NUM_RE.test(raw)) item({ ...one, kind: "상수", 값: 값 ?? Number(raw) });
|
||||
else if (LOOKUP_RE.test(raw)) item({ ...one, kind: "표", 표: tablesOf(raw) });
|
||||
else item({ ...one, kind: "계산", 식: readable(raw, aliases), 표: tablesOf(raw) });
|
||||
}
|
||||
const lines = textLines(o.text);
|
||||
for (const pct of [false, true])
|
||||
@@ -124,7 +130,8 @@ export function sheetItems(o: {
|
||||
비목: partTag(r),
|
||||
표: tablesOf(line?.이름글 ?? ""),
|
||||
row: r,
|
||||
changed: r.covered.qty || r.covered.price || r.added !== null,
|
||||
changed: r.covered.qty || r.covered.price || r.added !== null || !!o.covered?.(r.cid),
|
||||
자리: r.cid,
|
||||
});
|
||||
}
|
||||
const sums = o.ans?.기준 ?? null;
|
||||
@@ -139,19 +146,28 @@ export const num = (v: number | null | undefined): string => (v == null ? "—"
|
||||
|
||||
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
|
||||
export interface PaperLine {
|
||||
kind: "줄" | "소계" | "합계" | "머리";
|
||||
kind: "줄" | "소계" | "합계" | "머리" | "글";
|
||||
글: string;
|
||||
}
|
||||
export function paperLines(
|
||||
text: TextAnswer | null,
|
||||
rows: CoverRow[],
|
||||
ans: SkAnswer | null,
|
||||
notes: NoteLine[] = [],
|
||||
): PaperLine[] {
|
||||
if (!ans) return [];
|
||||
const out: PaperLine[] = [];
|
||||
const by = new Map(rows.map((r) => [r.cid, r]));
|
||||
const sums = ans.기준 ?? {};
|
||||
const groups = text?.ok ? text.groups : [];
|
||||
const shown = new Set(groups.flatMap((g) => g.줄.flatMap((l) => (l.옵션꺼짐 ? [] : [l.cid]))));
|
||||
const note = (spot: string): PaperLine[] =>
|
||||
notes.filter((x) => x.글 && x.자리 === spot).map((x) => ({ kind: "글", 글: x.글 }));
|
||||
out.push(
|
||||
...notes
|
||||
.filter((x) => x.글 && !shown.has(x.자리))
|
||||
.map((x) => ({ kind: "글" as const, 글: x.글 })),
|
||||
);
|
||||
for (const g of groups) {
|
||||
out.push({ kind: "머리", 글: g.비목 });
|
||||
for (const l of g.줄) {
|
||||
@@ -162,7 +178,7 @@ export function paperLines(
|
||||
: r
|
||||
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
|
||||
: num(l.금액);
|
||||
out.push({ kind: "줄", 글: `${l.이름} : ${body}` });
|
||||
out.push({ kind: "줄", 글: `${l.이름} : ${body}` }, ...(l.cid ? note(l.cid) : []));
|
||||
}
|
||||
const sub = sums[g.비목] ?? g.소계;
|
||||
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
|
||||
@@ -195,3 +211,112 @@ export const diffText = (now: number | null, was: number | null): string =>
|
||||
now == null || was == null || now === was
|
||||
? ""
|
||||
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
|
||||
|
||||
// ── 790-3 숫자 넓힘 화면 이음(790-2 셈 답 `시트` · 문서 `글줄`) ──────────────
|
||||
|
||||
/** 글 줄(셈 없음) — 자리 뒤 · "" = 맨 위 */
|
||||
export interface NoteLine {
|
||||
자리: string;
|
||||
글: string;
|
||||
}
|
||||
|
||||
/** 셈 답 `시트.자리[자리]`(790-2) */
|
||||
export interface SpotInfo {
|
||||
갈래: string;
|
||||
이름: string;
|
||||
식?: string;
|
||||
상수?: { n: number; 값: number | null; 원값: number | null }[];
|
||||
표값?: { n: number; 글: string; 값: unknown; 원값: unknown }[];
|
||||
원래?: number | null;
|
||||
지금?: unknown;
|
||||
덮개?: string[];
|
||||
까닭?: string | null;
|
||||
M01바뀜?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetSpots {
|
||||
자리: Record<string, SpotInfo>;
|
||||
글줄: NoteLine[];
|
||||
짝잃음: { cid: string; 까닭: string }[];
|
||||
}
|
||||
|
||||
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
|
||||
((ans as { 시트?: SheetSpots } | null)?.시트 ?? null) || null;
|
||||
|
||||
export type FormulaPart =
|
||||
{ t: "글"; 글: string } | { t: "수"; n: number } | { t: "표"; n: number; 표: string[] };
|
||||
|
||||
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*/u;
|
||||
const DIGITS = /^\d+(\.\d+)?/;
|
||||
|
||||
/** 식 글 → 숫자 자리(왼쪽부터 · 따옴표 글 · 이름 안 숫자 뺌) — 서버 `scan` 숫자 차례와 같음 */
|
||||
export function numSpots(s: string): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === "'" || c === '"') {
|
||||
const j = s.indexOf(c, i + 1);
|
||||
i = j < 0 ? s.length : j + 1;
|
||||
continue;
|
||||
}
|
||||
const rest = s.slice(i);
|
||||
const id = IDENT.exec(rest);
|
||||
const d = id ? null : DIGITS.exec(rest);
|
||||
if (d) out.push([i, i + d[0].length]);
|
||||
i += id ? id[0].length : d ? d[0].length : 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 엔진 식을 조각으로 — 글 · 숫자(n) · 표 찾기(n) · 서버 차례와 안 맞으면 null(숫자 칸 안 그림) */
|
||||
export function formulaParts(info: SpotInfo | undefined): FormulaPart[] | null {
|
||||
const s = info?.식 ?? "";
|
||||
const nums = info?.상수 ?? [];
|
||||
const finds = info?.표값 ?? [];
|
||||
if (!s || (!nums.length && !finds.length)) return null;
|
||||
const at = numSpots(s);
|
||||
if (at.length !== nums.length) return null;
|
||||
if (at.some(([a, b], i) => Number(s.slice(a, b)) !== Number(nums[i].원값))) return null;
|
||||
const cuts: { a: number; b: number; p: FormulaPart }[] = [];
|
||||
let from = 0;
|
||||
for (const f of finds) {
|
||||
const a = s.indexOf(f.글, from);
|
||||
if (a < 0) return null;
|
||||
from = a + f.글.length;
|
||||
cuts.push({ a, b: from, p: { t: "표", n: f.n, 표: tablesOf(f.글) } });
|
||||
}
|
||||
at.forEach(([a, b], n) => {
|
||||
if (!cuts.some((c) => c.a <= a && b <= c.b)) cuts.push({ a, b, p: { t: "수", n } });
|
||||
});
|
||||
cuts.sort((x, y) => x.a - y.a);
|
||||
const out: FormulaPart[] = [];
|
||||
const text = (x: string): void => {
|
||||
const 글 = x.replace(/\s*\*\s*/g, " × ");
|
||||
if (글) out.push({ t: "글", 글 });
|
||||
};
|
||||
let pos = 0;
|
||||
for (const c of cuts) {
|
||||
text(s.slice(pos, c.a));
|
||||
out.push(c.p);
|
||||
pos = c.b;
|
||||
}
|
||||
text(s.slice(pos));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 뺀 더한줄 되살림(차례 k 에 다시 끼움) 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j ≥ k) 한 칸 밂 · `dropAddKeys` 거꾸로 */
|
||||
export function liftAddKeys<T extends { 까닭?: Record<string, string>; 글줄?: NoteLine[] }>(
|
||||
doc: T,
|
||||
k: number,
|
||||
): T {
|
||||
const move = (key: string): string =>
|
||||
key.replace(/^add:#(\d+)/, (all, j: string) =>
|
||||
Number(j) >= k ? `add:#${Number(j) + 1}` : all,
|
||||
);
|
||||
const out = { ...doc };
|
||||
if (doc.까닭)
|
||||
out.까닭 = Object.fromEntries(Object.entries(doc.까닭).map(([x, v]) => [move(x), v]));
|
||||
if (doc.글줄) out.글줄 = doc.글줄.map((x) => ({ ...x, 자리: move(x.자리) }));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -178,3 +178,97 @@ def test_줄_갈래_읽기_쉬운_식_산출서_상태줄():
|
||||
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
|
||||
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
|
||||
assert got["diff"] == ["+383", "−419", ""]
|
||||
|
||||
|
||||
# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ──────────
|
||||
EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts"
|
||||
|
||||
|
||||
def test_790_3_숫자_넓힘_칸_정적():
|
||||
s = _text(EDIT)
|
||||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||||
assert "fetch(" not in s and "sessionStorage." not in s and len(s.splitlines()) < 700
|
||||
for call in (
|
||||
"setConst(c.doc, spot, p.n, x)", # 식 속 숫자 = 상수 덮개
|
||||
"setTable(c.doc, spot, p.n, x)", # 표 꼬리표 값 = 표값 덮개
|
||||
'createBadge(t("Cut"), "warning")', # 표 연결 끊김
|
||||
"addText(c.doc, spot,", # 글 줄 더함 · 고침 · 지움
|
||||
"editText(c.doc, i, v)",
|
||||
"dropText(c.doc, i)",
|
||||
"setReason(c.doc, key, x)", # 까닭
|
||||
"restoreM01(c.doc, spot)", # 줄마다 M01 원래로
|
||||
"restoreM01(c.doc)", # 전체
|
||||
'createBadge(t("Changed"), "danger")', # M01 바뀜
|
||||
"c.spots?.짝잃음",
|
||||
):
|
||||
assert call in s, call
|
||||
sheet = _text(SHEET)
|
||||
for call in (
|
||||
"formulaEditor(ctx, it.자리)",
|
||||
"reasonCell(ctx, it.자리, it.changed)",
|
||||
"spotTail(ctx, it.자리,",
|
||||
"restoreAll(ctx)",
|
||||
"lostBanner(ctx)",
|
||||
"removedReasons(ctx, d.removed)",
|
||||
"dropAddKeys(d.doc as ConstDoc, r.added)", # 뺀 더한줄 = 까닭 · 글줄 열쇠 당김
|
||||
"liftAddKeys(d.doc as ConstDoc, at)", # 되살림 = 거꾸로 밂
|
||||
"paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄)", # 글 줄 산출서에
|
||||
"spots: spotsOf(d.ans)",
|
||||
):
|
||||
assert call in sheet, call
|
||||
assert "Object.assign(doc, next);" in sheet # 상세가 쥔 같은 문서 객체 · 자동저장 없음
|
||||
assert len(sheet.splitlines()) < 700
|
||||
|
||||
|
||||
SPOT = {
|
||||
"식": "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)",
|
||||
"상수": [{"n": 0, "값": 1, "원값": 1}, {"n": 1, "값": 0, "원값": 0},
|
||||
{"n": 2, "값": 17, "원값": 17}, {"n": 3, "값": 100, "원값": 100}],
|
||||
"표값": [{"n": 0, "글": "찾기(QC000431, 유형=유형).시공량", "값": 45, "원값": 45}],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run_e2():
|
||||
src = IMPORTS.sub("", _text(MODEL))
|
||||
bad = {**SPOT, "상수": SPOT["상수"][:3]}
|
||||
body = (
|
||||
f"\nconst S = {json.dumps(SPOT, ensure_ascii=False)};"
|
||||
f"\nconst B = {json.dumps(bad, ensure_ascii=False)};"
|
||||
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
|
||||
"\nconst show = (p) => p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');"
|
||||
"\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' },"
|
||||
" { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];"
|
||||
"\nconsole.log(JSON.stringify({"
|
||||
"\n parts: show(formulaParts(S)),"
|
||||
"\n bad: formulaParts(B),"
|
||||
"\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] }),"
|
||||
"\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\"),"
|
||||
"\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글),"
|
||||
"\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' },"
|
||||
" 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1),"
|
||||
"\n spotsNone: spotsOf(null),"
|
||||
"\n}));"
|
||||
) # fmt: skip
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_790_3_식_조각_글줄_산출서_열쇠_밂():
|
||||
got = _run_e2()
|
||||
assert got["parts"] == (
|
||||
"[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])"
|
||||
) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸
|
||||
assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림
|
||||
assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌
|
||||
paper = got["paper"]
|
||||
assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로
|
||||
i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3")
|
||||
assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음
|
||||
assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"}
|
||||
assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}]
|
||||
assert got["spotsNone"] is None
|
||||
|
||||
Reference in New Issue
Block a user