Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-11-05 17:41:55 +09:00
7 changed files with 394 additions and 9 deletions
@@ -105,7 +105,7 @@ export function mountSangeunDetail(
const editor = el("div", { className: "m01-logic__editor" });
const sheet = createSangeunSheet(editor, {
...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) },
...{ addLine: () => addButton.click(), addRate: () => rateButton.click() }, // 790-1 시트 첫 탭
...{ addLine: () => addButton.click(), addRate: () => rateButton.click(), scope }, // 790-1 · 792 식 검사 층
});
const calcHost = el("div", { className: "m01lab__calc" });
const ownHost = el("div");
@@ -124,3 +124,19 @@
color: var(--color-text-secondary);
font-style: italic;
}
/* 792 식 칸 — 칸 + 검사 알림 한 줄 */
.m02-sks__fx {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-sks__fx .ui-field {
width: 100%;
}
.m02-sks__fx .ui-text:empty {
display: none;
}
@@ -7,6 +7,7 @@
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
* 792 식 칸(줄 식 고침 · 칠 때마다 검사 · 변수 줄) = `_Sangeun_SheetFormula`
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
* ========================================================================== */
@@ -32,8 +33,15 @@ import {
removedReasons,
restoreAll,
spotTail,
type EditCtx,
} from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVarButton,
formulaToggle,
rowFormula,
varRows,
type FormulaCtx,
} from "./M02_MasterTemplete_Sangeun_SheetFormula";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model";
import {
dropLine,
@@ -117,6 +125,8 @@ export interface SheetOptions {
onInput: (name: string, value: string) => void;
addLine: () => void;
addRate: () => void;
/** 792 식 검사 길 층 */
scope: { layer: Layer; owner: string | null };
}
type View = "sheet" | "ho" | "est" | "calc" | "formula";
@@ -132,7 +142,9 @@ 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 ctx: FormulaCtx | null = null; // 790-3 숫자 넓힘 · 792 식 칸 — 그릴 때마다 새로
const open = new Set<string>(); // 식 칸 연 자리
const drafts = new Map<string, string>(); // 치는 중 식 글
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
const show = (v: View): void => {
host.hidden = v !== "sheet";
@@ -239,8 +251,15 @@ export function createSangeunSheet(
if (!opt.edit) return [];
const parts = Object.keys(r.parts);
const part = PART_OF[parts[0] ?? ""];
const fx = coverKinds(d.doc as ConstDoc, r.cid).includes("식"); // 식 덮개 = 품 칸 안 둠(792)
const cells = [
numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) => setCell(d, r, "qty", x)),
...(fx
? []
: [
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))]
: []),
@@ -254,6 +273,7 @@ export function createSangeunSheet(
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
: [];
if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
if (ctx && fx(it)) kids.push(...formulaToggle(ctx, it.자리, ctx.spots?.자리[it.자리]));
if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null));
if (r && opt.edit) {
const drop = createButton({
@@ -310,8 +330,13 @@ export function createSangeunSheet(
return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) });
};
/** 식 칸 되는 줄 — 율 · 로직 더한줄 · 둘째 기초 세부 줄 뺌 */
const fx = (it: SheetItem): boolean =>
!!it.자리 && !(it.row?.sub || (it.row?.added != null && it.row.pct));
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
const nums = ctx && it.자리 ? formulaEditor(ctx, it.자리) : null;
const own = ctx && fx(it) ? rowFormula(ctx, it.자리, ctx.spots?.자리[it.자리]) : null;
const nums = ctx && it.자리 && !own ? formulaEditor(ctx, it.자리) : null;
const shown = it.식 || (it.kind === "계산" ? t("Hidden") : "");
const middle =
it.kind === "옵션"
@@ -321,6 +346,7 @@ export function createSangeunSheet(
children: [
...(it.row || !nums ? [createCalcFormula(shown)] : []),
...(nums ? [nums] : []),
...(own ? [own] : []),
...(it.row ? editLine(d, it.row) : []),
...(ctx ? reasonCell(ctx, it.자리, it.changed) : []),
],
@@ -331,7 +357,8 @@ export function createSangeunSheet(
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];
const after = ctx && it.자리 ? varRows(ctx, (v) => v.자리 === it.자리) : [];
return [row, ...notes, ...after];
}
/** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */
@@ -360,6 +387,10 @@ export function createSangeunSheet(
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
});
const spots = new Set(items.map((it) => it.자리).filter(Boolean));
const names = new Set(
((d.doc as { 변수줄?: { 이름: string }[] }).변수줄 ?? []).map((v) => v.이름),
);
const varSpot = (s: string): boolean => s.startsWith("mid:") && names.has(s.slice(4));
const out: HTMLElement[] = ctx ? lostBanner(ctx) : [];
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
opt.edit
@@ -375,7 +406,9 @@ export function createSangeunSheet(
? add("AddRate", opt.addRate)
: sec === "근거" && ctx
? addTopNote(ctx)
: [];
: sec === "상수" && ctx
? addVarButton(ctx)
: [];
if (!mine.length && !tools.length) continue;
out.push(head(sec, tools));
if (sec === "자원")
@@ -383,6 +416,8 @@ export function createSangeunSheet(
...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)),
...(ctx ? removedReasons(ctx, d.removed) : []),
);
if (sec === "상수" && ctx)
out.push(...varRows(ctx, (v) => !v.자리 || (!spots.has(v.자리) && !varSpot(v.자리))));
for (const it of mine) out.push(...rowOf(d, it));
if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리)));
}
@@ -418,6 +453,10 @@ export function createSangeunSheet(
spots: spotsOf(d.ans),
apply: (next) => apply(d, next),
focus: (key) => void (focusNext = key),
scope: opt.scope,
open,
drafts,
redraw: () => last && draw(last),
};
drawStatus(d);
const active = document.activeElement as HTMLElement | null;
@@ -103,7 +103,7 @@ function numBox(
/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */
export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null {
const info = c.spots?.자리[spot];
const parts = c.edit ? formulaParts(info) : null;
const parts = c.edit && !info?.식덮개 ? formulaParts(info) : null;
if (!info || !parts) return null;
const nums = c.doc.상수덮개?.[spot] ?? {};
const finds = c.doc.표값덮개?.[spot] ?? {};
@@ -251,7 +251,8 @@ export function restoreAll(c: EditCtx): HTMLElement[] {
Object.keys(d.덮개 ?? {}).length +
(d.뺀줄 ?? []).length +
Object.keys(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"),
@@ -0,0 +1,287 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetFormula.ts
* 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기)
* → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈
* 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`)
* 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음
* 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { 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 { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVar,
dropVar,
editVar,
setFormula,
type FormulaDoc,
type VarLine,
} from "./logic/M02_Sangeun_Formula_Model";
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Formula: ["식", "Formula"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Var: ["변수", "Variable"],
VarName: ["변수 이름", "Variable name"],
AddVar: ["+ 변수 줄", "+ Variable"],
DropVar: ["변수 빼기", "Remove variable"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} 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 CheckAnswer {
되나: boolean;
까닭: string[];
알림: string[];
/** 엔진 식(문서에 넣을 것) */
식: string;
/** 이름 꼴 글 */
글: string;
}
export interface FormulaCtx extends EditCtx {
scope: { layer: Layer; owner: string | null };
/** 식 칸 연 자리(화면 캐시) */
open: Set<string>;
/** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */
drafts: Map<string, string>;
redraw: () => void;
}
const q = (c: FormulaCtx): string =>
c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : "";
/** 검사 길(792-1) — 셈 · 저장 없음 */
export const checkFormula = (
c: FormulaCtx,
spot: string,
text: string,
signal?: AbortSignal,
): Promise<CheckAnswer> =>
call<CheckAnswer>(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, {
method: "POST",
body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }),
signal,
});
/** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */
export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
const out: FormulaDoc = { ...doc };
for (const name of ["상수덮개", "표값덮개"] as const) {
const all = { ...(doc[name] ?? {}) };
delete all[spot];
if (Object.keys(all).length) out[name] = all;
else delete out[name];
}
const one = doc.덮개?.[spot];
if (one && "qty" in one) {
const rest = { ...one };
delete rest.qty;
const covers = { ...doc.덮개 };
if (Object.keys(rest).length) covers[spot] = rest;
else delete covers[spot];
out.덮개 = covers;
}
return out as T;
}
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */
function formulaBox(
c: FormulaCtx,
spot: string,
key: string,
shown: string,
origin: string,
put: (engine: string | null) => void,
): HTMLElement {
const box = createInputField({
value: c.drafts.get(key) ?? shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = key;
let timer = 0;
let ctl: AbortController | null = null;
let last: Promise<CheckAnswer | null> | null = null;
const run = (text: string): Promise<CheckAnswer | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
last = checkFormula(c, spot, text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = got.알림.join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
return last;
};
box.input.addEventListener("input", () => {
c.drafts.set(key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === origin) {
c.drafts.delete(key);
return put(null);
}
const got = await run(text);
if (!got?.되나) return void (note.textContent = t("Bad"));
c.drafts.delete(key);
put(got.식);
});
return el("div", {
className: "m02-sks__fx",
attrs: { "data-sheet-fx": spot },
children: [box.root, note],
});
}
/** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */
export function rowFormula(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement | null {
if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null;
const shown = info.식글 ?? info.식 ?? "";
const origin = info.원식글 ?? "";
return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => {
const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc);
c.apply(setFormula(doc, spot, engine));
});
}
/** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */
export function formulaToggle(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement[] {
const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌)
if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return [];
const on = c.open.has(spot);
const b = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) c.open.delete(spot);
else c.open.add(spot);
c.drafts.delete(`${spot}|f`);
c.focus(on ? "" : `${spot}|f`);
c.redraw();
},
});
b.dataset.sheetFxOpen = spot;
return [b];
}
const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? [];
/** 자리 뒤 변수 줄들(이어 붙은 변수까지) */
export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] {
return vars(c).flatMap((v) =>
after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [],
);
}
function varRow(c: FormulaCtx, v: VarLine): HTMLElement {
const spot = `mid:${v.이름}`;
const info = c.spots?.자리[spot];
const label = c.edit
? (() => {
const box = createInputField({ value: v.이름, compact: true, size: 24 });
box.input.setAttribute("aria-label", t("VarName"));
box.input.dataset.sheetCell = `var|${v.이름}`;
box.input.addEventListener("change", () => {
const to = box.input.value.trim();
if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to))
return void (box.input.value = v.이름);
c.focus(`var|${to}`);
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to }));
});
return box.root;
})()
: el("span", { className: "ui-calc-row__label", text: v.이름 });
const middle = c.edit
? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) =>
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })),
)
: el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 });
const value = el("span", {
className: "ui-calc-row__out",
text: typeof info?.지금 === "number" ? num(info.지금) : "—",
});
const tail = el("span", {
children: [
createBadge(t("Var"), "accent"),
...(c.edit
? [
createButton({
label: t("DropVar"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)),
}),
]
: []),
],
});
const row = createCalcSheetRow(label, middle, value, tail);
row.dataset.sheetKind = "변수";
row.dataset.sheetSpot = spot;
row.classList.add("is-changed");
return row;
}
/** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */
export function addVarButton(c: FormulaCtx): HTMLElement[] {
if (!c.edit) return [];
const b = createButton({
label: t("AddVar"),
variant: "ghost",
size: 22,
onClick: () => {
const used = new Set([
...vars(c).map((v) => v.이름),
...(c.spots?.이름표 ?? []).map((x) => x.글),
]);
let n = 1;
while (used.has(`변수${n}`)) n++;
c.focus(`var|변수${n}`);
c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0));
},
});
b.dataset.sheetAddVar = "";
return [b];
}
@@ -232,12 +232,18 @@ export interface SpotInfo {
덮개?: string[];
까닭?: string | null;
M01바뀜?: boolean;
/** 792-1 — 지금 식 · M01 식(이름 꼴) · 식 덮개 있음 */
식글?: string;
원식글?: string;
식덮개?: boolean;
}
export interface SheetSpots {
자리: Record<string, SpotInfo>;
글줄: NoteLine[];
짝잃음: { cid: string; 까닭: string }[];
/** 792-1 식 칸에 쓸 수 있는 이름 */
이름표?: { 글: string; 갈래: string }[];
}
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
@@ -272,3 +272,39 @@ def test_790_3_식_조각_글줄_산출서_열쇠_밂():
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
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
def test_792_식_칸_정적():
s = _text(FX)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
for call in (
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
"signal,",
'box.input.addEventListener("input"',
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
"if (!got?.되나) return", # 틀린 식은 문서에 안 넣음
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
"addVar(c.doc as FormulaDoc,",
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
"dropVar(c.doc as FormulaDoc, v.이름)",
):
assert call in s, call
sheet = _text(SHEET)
for call in (
"rowFormula(ctx, it.자리,",
"formulaToggle(ctx, it.자리,",
"varRows(ctx, (v) => v.자리 === it.자리)",
"addVarButton(ctx)",
"scope: opt.scope,",
'coverKinds(d.doc as ConstDoc, r.cid).includes("식")', # 식 덮개 = 품 칸 안 둠
):
assert call in sheet, call
assert "addRate: () => rateButton.click(), scope }" in _text(DETAIL) # 탭 등록 줄에 층만
assert "fetch(" not in s # 길 = Api_Fetch.call(로그인 쿠키)