feat(717-2b): M02 단가산출 상세 · 고침 — M01 상세 부품(보기 넷)을 M02 길로 · 품 · 단가 칸 덮개 · 줄 더하기 · 빼기 · 바탕 M01 로직 고름
- 새 Sangeun_Detail(mountSangeunDetail) · Sangeun_Cover(고침 표 · 셈 길 최신 응답만) · logic/M02_Sangeun_Cover_Model(순수 꼴) - 고친 칸 = 칸 색 + 바탕 값 곁 글 + 「단가산출에서 고침」 · M01 개정 배지(바탕판 · 원값) · 짝잃음 띠 · 별도계상 재료 = 계 밖 표기 - M01 buildLabDetail 덧칸 cover(단가산출 상세 · 인력·재료·경비 덩이 갈음) · 마스터 이동 단추 = 등록 없으면 숨김(hasMasterJump) - Sangeun_View 자리표 · 옛 글 열쇠(준비 중 · 덮개 · 더한 줄 · 열린 입력) 걷음 - 시험 test_717_2b_sangeun_detail 3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Detail.ts
|
||||
* M02 단가산출(SK) 상세(PLAN 717-2b · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
|
||||
* 보기 넷(`LogicLab_View`) = 단가산출 상세(고침 표 · `Sangeun_Cover`) · 인력·재료·경비(공용 계산서 · SK 셈 답) ·
|
||||
* 시험 계산(`Logic_Calc` · 바탕 M01 로직) · 텍스트 수식(바탕 M01 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘)
|
||||
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
|
||||
* 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
|
||||
* 바탕 없는 SK = [M01 로직 고름] → 바탕 · 단위 · 바탕 없이 줄만도 됨
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
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 { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
|
||||
import {
|
||||
fetchLogic,
|
||||
setLogicApiPaths,
|
||||
type LogicOne,
|
||||
type LogicRow,
|
||||
type TextAnswer,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
|
||||
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { calcSangeun, coverTable } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import {
|
||||
dropLine,
|
||||
restoreLine,
|
||||
rowsOf,
|
||||
setPrice,
|
||||
setQty,
|
||||
sheetGroups,
|
||||
type SkAnswer,
|
||||
type SkDoc,
|
||||
} from "./logic/M02_Sangeun_Cover_Model";
|
||||
|
||||
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;
|
||||
/** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */
|
||||
const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim();
|
||||
|
||||
export function mountSangeunDetail(
|
||||
host: HTMLElement,
|
||||
doc0: unknown,
|
||||
opt: SangeunDetailOptions,
|
||||
): { getDoc: () => SkDoc; 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 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) => {
|
||||
doc.더한줄 = [
|
||||
...(doc.더한줄 ?? []),
|
||||
line as unknown as NonNullable<SkDoc["더한줄"]>[number],
|
||||
];
|
||||
touch();
|
||||
}),
|
||||
});
|
||||
addButton.hidden = !edit;
|
||||
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;
|
||||
const baseKey = (): string | null => doc.바탕?.로직 ?? null;
|
||||
|
||||
/** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */
|
||||
const standIn = (): LogicRow => ({
|
||||
키: doc.키 ?? "",
|
||||
원문번호: "",
|
||||
이름: doc.이름 ?? "",
|
||||
결과단위: `원/${doc.단위 ?? ""}`,
|
||||
출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"),
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
});
|
||||
|
||||
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;
|
||||
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 = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판);
|
||||
const pick = createButton({
|
||||
label: L("M02_SangeunPickBase"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
doc.바탕 = { 로직: x.키 };
|
||||
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
|
||||
if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위;
|
||||
touch(false);
|
||||
void loadBase();
|
||||
}),
|
||||
});
|
||||
pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length;
|
||||
head.replaceChildren(
|
||||
field(L("M02_Name"), "이름"),
|
||||
field(L("M02_SangeunUnit"), "단위"),
|
||||
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")] : []),
|
||||
],
|
||||
}),
|
||||
pick,
|
||||
);
|
||||
}
|
||||
|
||||
function drawNotes(): void {
|
||||
const lost = ans?.짝잃음 ?? [];
|
||||
const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판);
|
||||
notes.replaceChildren(
|
||||
...(fail || ans?.까닭
|
||||
? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })]
|
||||
: []),
|
||||
...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []),
|
||||
...(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 ho = coverTable(rows, {
|
||||
edit,
|
||||
opened: opened(),
|
||||
tag: L("M02_SangeunEditedHere"),
|
||||
total: ans?.기준?.계 ?? null,
|
||||
removed: (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })),
|
||||
onQty: (r, v) => (setQty(doc, r, v, opened()), touch()),
|
||||
onPrice: (r, v) => (setPrice(doc, r, v), touch()),
|
||||
onDrop: (r) => (dropLine(doc, r), touch()),
|
||||
onRestore: (cid) => (restoreLine(doc, cid), touch()),
|
||||
});
|
||||
const row = base?.logic ?? standIn();
|
||||
const est = el("div", {
|
||||
attrs: { "data-est": "" },
|
||||
children: ans
|
||||
? calcSheetRows({
|
||||
묶음: sheetGroups(rows),
|
||||
계: ans.기준?.계 ?? null,
|
||||
sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`,
|
||||
bundle: true,
|
||||
inputs: row.입력,
|
||||
values,
|
||||
onInput,
|
||||
})
|
||||
: [createText(L("M02_SangeunWait"))],
|
||||
});
|
||||
buildLabDetail(editor, {
|
||||
row,
|
||||
file: base?.file ?? "",
|
||||
reasons: base?.reasons ?? [],
|
||||
prices: base?.prices ?? {},
|
||||
lines: null,
|
||||
text,
|
||||
lastText,
|
||||
values,
|
||||
ownHost,
|
||||
onChange: () => undefined,
|
||||
calcHost,
|
||||
onInput,
|
||||
cover: { ho, est },
|
||||
});
|
||||
}
|
||||
|
||||
/** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */
|
||||
function onInput(name: string, v: string): void {
|
||||
values[name] = v;
|
||||
drawCalc();
|
||||
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
}
|
||||
|
||||
function drawCalc(): void {
|
||||
if (!base) {
|
||||
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
|
||||
return;
|
||||
}
|
||||
buildCalc(calcHost, {
|
||||
savedKey: base.logic.키,
|
||||
file: base.file,
|
||||
row: base.logic,
|
||||
prices: base.prices,
|
||||
dirty: () => false,
|
||||
values,
|
||||
onText: (t) => {
|
||||
text = t;
|
||||
if (t.ok) lastText = t;
|
||||
draw();
|
||||
},
|
||||
onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBase(): 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;
|
||||
drawCalc();
|
||||
draw();
|
||||
await recalc();
|
||||
}
|
||||
|
||||
void loadBase();
|
||||
return {
|
||||
getDoc: () => {
|
||||
if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음)
|
||||
saved = JSON.stringify(doc);
|
||||
return clone(doc);
|
||||
},
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
host.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user