Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts
T
eomsangdonandClaude Sonnet 5.5 0c0d0ce629 feat(748-2): 텍스트 수식 — 행 고르기 걷음 · 변수 이름 · 값 하나하나 누르면 줄 모달(748-1) · 줄마다 옵션 값 칩 걷음(748-3 한 조각)
- 텍스트 수식 줄 hover · 누름 고정(행 단위 선택) 걷음
- 변수 이름 식 = 변수 이름마다 낮은 단추 · 값 든 식 = 수 하나하나 낮은 단추 → 같은 줄의 상세 모달(적용 · 삭제 · 닫기)
- 변수가 시험 입력(옵션)이면 옵션 고르기 칩으로
- 공용 상세 `lineOpener` 새로 — M02 SK 상세 · M01 단가산출 기초 같은 길(M01 = 시험 고침 고리)
- 상세 보기 줄마다 붙던 옵션 값 칩(`쓰는입력`) 걷음 — 위 옵션 칸이 대신(748-3)
- 새 파일 `LogicLab_FormulaTokens`(조각 나누기 순수 셈) · 시험 test_748_2 · 낡은 시험 고침 test_722(inputChip 줄 걷음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:15:33 +09:00

531 lines
20 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Sangeun_Detail.ts
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
* 보기 넷(`LogicLab_View`) = 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 인력·재료·경비(공용 계산서 · SK 셈 답) ·
* 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘)
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
* 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 ·
* 뺀줄 · [기초 빼기] · M01 개정 알림 · 옵션 칸 = 바탕 입력 + 더한줄 기초 입력
* [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { calcSheetRows } from "../A00_Common/calc_sheet";
import { swapUnit } from "../A00_Common/master_unit_swap";
import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import {
fetchLogic,
setLogicApiPaths,
type CalcLine,
type LogicInput,
type LogicOne,
type LogicRow,
type TextAnswer,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { applyCovers } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Cover";
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
baseCovers,
dropBase,
dropLine,
dropText,
goneRows,
hoView,
removedSubs,
restoreLine,
revisedAdds,
rowsOf,
setApart,
setLineCover,
sheetGroups,
type CoverRow,
type HoView,
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo";
export interface SangeunDetailOptions {
layer: Layer;
owner: string | null;
readOnly: boolean;
onChanged: (dirty: boolean) => void;
}
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function mountSangeunDetail(
host: HTMLElement,
doc0: unknown,
opt: SangeunDetailOptions,
): { getDoc: () => SkDoc; getValues: () => Record<string, string>; destroy: () => void } {
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
const doc = clone((doc0 ?? {}) as SkDoc);
let saved = JSON.stringify(doc);
const edit = !opt.readOnly;
const scope = { layer: opt.layer, owner: opt.owner };
const values: Record<string, string> = Object.fromEntries(
Object.entries(doc.입력값 ?? {}).map(([k, v]) => [k, String(v)]),
);
let base: LogicOne | null = null;
let text: TextAnswer | null = null;
let lastText: TextAnswer | undefined;
let ans: SkAnswer | null = null;
let bare: SkAnswer | null = null;
let fail = "";
let extra: LogicInput[] = []; // 더한줄 로직 입력 중 바탕에 없는 것 = 옵션 칸(725-6 · 727-3)
const inputsOf = new Map<string, LogicInput[]>();
let seq = 0;
let alive = true;
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
const notes = el("div");
const editor = el("div", { className: "m01-logic__editor" });
const views = createViewPicker(editor);
const calcHost = el("div", { className: "m01lab__calc" });
const ownHost = el("div");
const addButton = createButton({
label: L("M02_SangeunAddLine"),
variant: "ghost",
size: 28,
onClick: () =>
openSangeunLinePicker(scope, (line) => {
if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직);
if ("종류" in line && line.종류 === "로직") return void addBase(line);
doc.더한줄 = [
...(doc.더한줄 ?? []),
withFirst(line as unknown as NonNullable<SkDoc["더한줄"]>[number]),
];
touch();
}),
});
addButton.hidden = !edit;
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
let qty = 1;
const have = base ? baseUnitOf(base.logic.결과단위) : "";
try {
const swap = have
? swapUnit(have, baseUnitOf((await fetchLogic(line.로직)).logic.결과단위))
: null;
if (swap?.배 != null && swap.계수 === null) qty = swap.배;
} catch {
/* 못 읽음 = 1 그대로 */
}
if (!alive) return;
doc.더한줄 = [
...(doc.더한줄 ?? []),
withFirst({ ...line, 수량: qty } as unknown as NonNullable<SkDoc["더한줄"]>[number]),
];
touch();
}
/** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */
function setBase(key: string): void {
doc.바탕 = { 로직: key };
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
touch(false);
void loadBase(true);
}
const baseHint = createText(L("M02_SangeunBaseViews"));
host.replaceChildren(
el("div", {
className: "m02-sk",
attrs: { "data-sangeun-detail": doc.키 ?? "" },
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }),
editor,
],
}),
);
const opened = (): boolean => (doc.열린입력 ?? []).length > 0;
// 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2)
const revisedBase = (): boolean =>
!!(doc.바탕 && doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판) ||
revisedAdds(ans, doc).length > 0;
const baseKey = (): string | null => doc.바탕?.로직 ?? null;
/** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */
const standIn = (): LogicRow => ({
키: doc.키 ?? "",
원문번호: "",
이름: doc.이름 ?? "",
결과단위: `원/${doc.단위 ?? ""}`,
출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"),
입력: extra,
중간: [],
호표: [],
덧줄: [],
});
/** 더한줄 로직들의 입력을 모음(같은 이름은 하나 · 바탕 입력에 있는 이름 뺌) */
async function syncExtra(): Promise<void> {
const keys = [
...new Set((doc.더한줄 ?? []).flatMap((x) => ("로직" in x ? [String(x.로직)] : []))),
];
await Promise.all(
keys
.filter((k) => !inputsOf.has(k))
.map((k) =>
fetchLogic(k).then(
(r) => void inputsOf.set(k, r.logic.입력 ?? []),
() => void inputsOf.set(k, []),
),
),
);
const seen = new Set<string>((base?.logic.입력 ?? []).map((i) => i.이름));
extra = keys
.flatMap((k) => inputsOf.get(k) ?? [])
.filter((i) => !seen.has(i.이름) && seen.add(i.이름));
}
const touch = (again = true): void => {
opt.onChanged(edit && JSON.stringify(doc) !== saved);
if (again) void recalc();
else drawHead();
};
async function recalc(): Promise<void> {
const mine = ++seq;
await syncExtra();
if (mine !== seq || !alive) return;
const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] };
try {
const [got, was] = await Promise.all([
calcSangeun(scope, doc, values),
doc.바탕 ? calcSangeun(scope, plain, values) : Promise.resolve(null),
]);
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
[ans, bare, fail] = [got, was, ""];
} catch (error) {
if (mine !== seq || !alive) return;
[ans, fail] = [null, error instanceof Error ? error.message : String(error)];
}
draw();
}
function drawHead(): void {
const field = (label: string, key: "이름" | "단위"): HTMLElement => {
const box = createInputField({ label, value: doc[key] ?? "", compact: true, size: 28 });
box.input.dataset.sk = key;
box.input.disabled = !edit;
box.input.addEventListener("change", () => {
doc[key] = box.input.value.trim();
box.setError(doc[key] ? undefined : L("M02_SangeunNeed"));
touch(key === "단위");
});
return box.root;
};
const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null;
const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
const revised = revisedBase();
const swap = base
? buildUnitSwap({
result: base.logic.결과단위,
unit: doc.단위 ?? "",
how: doc.환산 as SwapHow | undefined,
edit,
onChange: (how) => {
if (how) doc.환산 = how;
else delete doc.환산;
touch();
},
})
: null;
head.replaceChildren(
field(L("M02_Name"), "이름"),
field(L("M02_SangeunUnit"), "단위"),
...(swap ? [swap] : []),
el("div", {
className: "m02-sk__tags",
attrs: { "data-sk-base": baseKey() ?? "" },
children: [
createText(`${L("M02_SangeunBase")} · ${baseText}`, "muted", 14),
...(ans?.머리 ? [createBadge(ans.머리, "info")] : []),
...(revised ? [createBadge(L("M02_SangeunRevisedBase"), "danger")] : []),
...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []),
],
}),
);
}
function drawNotes(): void {
const lost = ans?.짝잃음 ?? [];
const gaps = ans?.빠진줄 ?? [];
const revised = revisedBase();
notes.replaceChildren(
...(fail || ans?.까닭
? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })]
: []),
...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []),
...(gaps.length
? [
createBanner({
tone: "warning",
text: `${L("M02_SangeunGap")} — ${gaps.map((x) => x.이름 || x.cid).join(" · ")}`,
details: gaps.map((x) => `${x.이름 || x.cid} — ${x.까닭}`).join("\n"),
}),
]
: []),
...(lost.length
? [
createBanner({
tone: "warning",
text: L("M02_SangeunLost"),
details: lost.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
}),
]
: []),
);
}
function draw(): void {
if (!alive) return;
drawHead();
drawNotes();
baseHint.hidden = !base;
const rows = ans ? rowsOf(ans, bare, doc) : [];
const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름]));
const removed = [
...(doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })),
...removedSubs(ans),
...goneRows(doc), // 뺀 더한줄(744-1)
];
const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn();
const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null;
const baseNames = [...(row.호표 ?? []), ...(row.덧줄 ?? [])].map((x) =>
String(x.이름 ?? ("요소" in x ? x.요소 : "")),
);
const cut = <T extends TextAnswer | undefined>(t: T): T =>
(t ? dropText(t, baseNames, bare, doc) : t) as T;
const hoCtx = {
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
prices: base?.prices ?? {},
lines: (view?.lines ?? null) as CalcLine[] | null,
text: cut(text ?? undefined) ?? null,
lastText: cut(lastText),
values,
onInput,
calcHost,
edit: view && edit ? hoEdit(view, rows) : undefined,
};
const ho = el("div", {
attrs: { "data-sk-cover": "" },
children: [
...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())),
...baseHeads({
doc,
ans,
edit,
onQty: (k, qty) => {
const one = doc.더한줄?.[k];
if (one) setQty(one, qty * (ans?.화면배 ?? 1));
touch();
},
onReset: (k) => {
const one = doc.더한줄?.[k];
if (one) resetQty(one);
touch();
},
onDrop: (k, name) => (dropBase(doc, k, name), touch()),
}),
...buildHo(hoCtx),
],
});
const est = el("div", {
attrs: { "data-est": "" },
children: ans
? calcSheetRows({
묶음: sheetGroups(rows),
계: ans.기준?.계 ?? null,
sumLabel: `${L("M02_SangeunTotal")} (${doc.단위 && doc.단위 !== "식" ? `원/${doc.단위}` : row.결과단위 || "원"})`,
bundle: true,
inputs: row.입력,
values,
onInput,
})
: [createText(L("M02_SangeunWait"))],
});
buildLabDetail(editor, {
row: base?.logic ?? row,
file: base?.file ?? "",
reasons: base?.reasons ?? [],
prices: base?.prices ?? {},
lines: null,
text,
lastText,
values,
ownHost,
onChange: () => undefined,
calcHost,
onInput,
cover: { ho, est, open: lineOpener(hoCtx) }, // 748-2 텍스트 수식 변수 · 값 누름 = 같은 줄 모달
});
}
/** 공용 상세 고침 고리 — 열쇠 = Ho 줄 차례 → cid · 고침 = SK 덮개 · 더한줄(M01 안 바뀜 · 내역 17-2) ·
* 모달 덧 동작 = 줄 빼기 · 더한 재료 별도계상 */
function hoEdit(view: HoView, rows: CoverRow[]): HoEdit {
const by = new Map(rows.map((r) => [r.cid, r]));
const at = (idx: number): CoverRow | undefined => by.get(view.cids[idx]);
return {
covers: view.covers,
scope: "m02",
set: (idx, c) => {
const r = at(idx);
if (!r) return;
setLineCover(doc, r, c, opened());
touch();
if (base) rerunCalc(); // 시험 계산 · 텍스트 수식 = 덮개 사본으로
},
more: (idx, close) => {
const r = at(idx);
if (!r) return { head: [], body: [] };
const drop = createButton({
label: L("M02_SangeunDrop"),
variant: "danger-ghost",
size: 32,
onClick: () => (close(), dropLine(doc, r), touch()),
});
drop.dataset.skDrop = "";
const sub = r.sub;
const whole = sub
? [
createButton({
label: L("M02_SangeunDropBase"),
variant: "danger-ghost",
size: 28,
onClick: () => (
close(),
dropBase(doc, sub.k, ans?.줄.find((x) => x.cid === `add:#${sub.k}`)?.이름),
touch()
),
}),
]
: [];
const apart = r.apartPick
? [
createCheckbox(L("M02_SangeunApart"), {
chip: true,
size: 28,
checked: r.apart,
onChange: (on) => (close(), setApart(doc, r, on), touch()),
}).root,
]
: [];
const rest = [...apart, ...whole];
return {
head: [drop],
body: rest.length ? [el("div", { className: "m02-sk__tags", children: rest })] : [],
};
},
};
}
function rerunCalc(): void {
drawCalc();
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
}
/** 옵션 칸 · 텍스트 수식 · 계산서 입력 단추 — 입력 고침 → SK `입력값`(727-3 · 열린입력 = 시험 값만) ·
* 시험 계산 다시 그림 · 셈 */
function onInput(name: string, v: string): void {
values[name] = v;
if (edit && !(doc.열린입력 ?? []).includes(name)) {
const kept = { ...(doc.입력값 ?? {}) };
if (String(v).trim()) kept[name] = v;
else delete kept[name];
doc.입력값 = kept;
touch(false);
}
drawCalc();
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
if (!base) void recalc(); // 바탕 없음 — 시험 계산 단추 없음 · SK 셈을 바로
}
function drawCalc(): void {
if (!base) {
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
return;
}
const covers = baseCovers(bare, base.logic.호표?.length ?? 0, doc);
const covered = Object.keys(covers).length > 0;
buildCalc(calcHost, {
savedKey: base.logic.키,
file: base.file,
row: covered ? applyCovers(base.logic, covers) : base.logic,
prices: base.prices,
dirty: () => covered, // 덮개 사본 = 저장 안 한 줄로 셈(메모리만)
values,
onText: (t) => {
text = t;
if (t.ok) lastText = t;
draw();
},
onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로
});
}
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */
async function loadBase(fresh = false): Promise<void> {
const key = baseKey();
base = null;
text = null;
if (key) {
try {
base = await fetchLogic(key);
} catch (error) {
fail = error instanceof Error ? error.message : String(error);
}
}
if (!alive) return;
if (fresh && base && (!doc.단위 || doc.단위 === "식")) {
doc.단위 = baseUnitOf(base.logic.결과단위) || doc.단위;
touch(false);
}
draw();
await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 · 텍스트 수식 = 덮개 사본
if (alive) drawCalc();
}
void loadBase();
return {
getDoc: () => {
if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음)
for (const l of ans?.줄 ?? []) {
const one = l.바탕판
? doc.더한줄?.[Number(/^add:#(\d+)$/.exec(l.cid)?.[1] ?? -1)]
: undefined;
if (one) one.바탕판 = l.바탕판; // 둘째 기초도 같음(727-2)
}
saved = JSON.stringify(doc);
return clone(doc);
},
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
destroy: () => {
alive = false;
host.replaceChildren();
},
};
}