- 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로) - M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움 - 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로 - 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈 - 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저 - 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Sangeun_Detail.ts
|
|
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
|
|
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(공용 수식 문서 화면 · 802-4b-2 · 저장 = 페이지 [저장] 한 번(804-8 · 수식 먼저 → 판 · 수식 칸 새로)) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
|
|
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 기본정보 = 바탕 로직(맨 위 접힘) — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 걷음
|
|
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
|
|
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
|
|
* 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 ·
|
|
* 뺀줄 · [기초 빼기] · M01 개정 알림 · 옵션 칸 = 바탕 입력 + 더한줄 기초 입력
|
|
* [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨
|
|
* 단가산출 화면(`source` · 804-8) = M01 과 같은 꼴 — 보기 고르기는 페이지 머리 줄(`slot`) · [항목 추가] · [율 줄 추가] 걷음
|
|
* (줄 더하기 = 수식 탭 [+] 단가산출 복사) · 수식 고침도 페이지 [저장] 한 번에(`flush` 먼저) · 고침 알림 = 기본 + 수식
|
|
* ========================================================================== */
|
|
|
|
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 { swapUnit } from "../A00_Common/master_unit_swap";
|
|
import {
|
|
fetchLogic,
|
|
setLogicApiPaths,
|
|
type CalcLine,
|
|
type LogicInput,
|
|
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 { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
|
|
import { readTemplate, 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 { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
|
|
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
|
|
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
|
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
|
|
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
|
|
import {
|
|
dropBase,
|
|
dropLine,
|
|
goneRows,
|
|
hoView,
|
|
removedSubs,
|
|
restoreLine,
|
|
revisedAdds,
|
|
rowsOf,
|
|
setApart,
|
|
setLineCover,
|
|
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;
|
|
/** 752-1 오른쪽 원문 패널 — 단가산출 화면만 켬(일위대가 줄 모달 안 상세는 끔) */
|
|
source?: boolean;
|
|
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
|
|
onVersion?: (version: string) => void;
|
|
/** 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에 */
|
|
slot?: HTMLElement;
|
|
}
|
|
|
|
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>;
|
|
/** 페이지 [저장] 앞 — 수식 고침 저장 · 실패면 던짐 */
|
|
flush: () => Promise<void>;
|
|
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[]>();
|
|
const infoOf = new Map<string, SourceTab>(); // 더한줄 로직 → 원문 탭(752-1)
|
|
let extraKeys: string[] = [];
|
|
const side = opt.source ? createSangeunSide() : null;
|
|
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" });
|
|
let refreshing: Promise<void> = Promise.resolve();
|
|
const tabs = createSangeunTabs(editor, {
|
|
edit,
|
|
scope,
|
|
onSaved: (v) => void (refreshing = formulaSaved(v)),
|
|
page: opt.source ? () => report() : undefined, // 804-8 페이지 [저장] · [되돌림]
|
|
});
|
|
/** 수식 탭 저장 = 파일 판 · `수식` 칸이 바뀜 — 페이지 판 · 이 화면 문서 `수식` 을 새로(상세 탭 셈이 씀) */
|
|
async function formulaSaved(version: string): Promise<void> {
|
|
opt.onVersion?.(version);
|
|
try {
|
|
const got = (await readTemplate(opt.layer, "sangeun", doc.키 ?? "", null, opt.owner))
|
|
.문서 as SkDoc;
|
|
if (!alive) return;
|
|
const was = JSON.parse(saved) as SkDoc;
|
|
doc.수식 = was.수식 = got.수식;
|
|
saved = JSON.stringify(was);
|
|
touch();
|
|
} catch {
|
|
/* 못 읽음 = 다음 열기 때 새로 */
|
|
}
|
|
}
|
|
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 || !!opt.source;
|
|
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
|
|
const rateButton = createButton({
|
|
label: L("M02_SkRateAdd"),
|
|
variant: "ghost",
|
|
size: 28,
|
|
onClick: () =>
|
|
openRateLine(
|
|
(ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })),
|
|
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
|
|
),
|
|
});
|
|
rateButton.hidden = !edit || !!opt.source;
|
|
/** 둘째 기초(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 main = el("div", {
|
|
className: "m02-sk",
|
|
attrs: { "data-sangeun-detail": doc.키 ?? "" },
|
|
children: [
|
|
head,
|
|
notes,
|
|
...(opt.slot
|
|
? []
|
|
: [
|
|
el("div", {
|
|
className: "m02-sk__head",
|
|
children: [tabs.picker, addButton, rateButton],
|
|
}),
|
|
]),
|
|
tabs.host,
|
|
editor,
|
|
],
|
|
});
|
|
host.replaceChildren(side ? side.wrap(main) : main);
|
|
opt.slot?.replaceChildren(
|
|
el("span", { className: "ui-toolbar m02-main__tools", children: [tabs.picker] }),
|
|
);
|
|
|
|
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) => {
|
|
inputsOf.set(k, r.logic.입력 ?? []);
|
|
infoOf.set(k, tabOf(r.logic));
|
|
},
|
|
() => void inputsOf.set(k, []),
|
|
),
|
|
),
|
|
);
|
|
extraKeys = keys;
|
|
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.이름));
|
|
}
|
|
|
|
/** 고침 알림 — 기본(이름 · 단위 · 옵션 · 덮개) + 수식 탭 */
|
|
function report(): void {
|
|
if (alive) opt.onChanged(edit && (JSON.stringify(doc) !== saved || tabs.dirty()));
|
|
}
|
|
const touch = (again = true): void => {
|
|
report();
|
|
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, false) : Promise.resolve(null), // 바탕 원값 = 보기 덧칸 없이
|
|
]);
|
|
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
|
|
[ans, bare, fail] = [got, was, ""];
|
|
text = got.글 ?? null; // 750-2 텍스트 수식 · 시험 계산 = 이 답 하나(바탕 로직 따로 셈 없음)
|
|
if (text?.ok) lastText = text;
|
|
} catch (error) {
|
|
if (mine !== seq || !alive) return;
|
|
[ans, text, fail] = [null, 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();
|
|
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;
|
|
tabs.draw(/^SK-\d{6}$/.test(doc.키 ?? "") ? (doc.키 ?? "") : "");
|
|
const hoCtx = {
|
|
row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row,
|
|
prices: base?.prices ?? {},
|
|
lines: (view?.lines ?? null) as CalcLine[] | null,
|
|
text,
|
|
lastText,
|
|
values,
|
|
onInput,
|
|
edit: view && edit ? hoEdit(view, rows) : undefined,
|
|
cids: view?.cids,
|
|
pickMat: (idx: number) => pickMat(view?.cids[idx]),
|
|
};
|
|
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),
|
|
],
|
|
});
|
|
buildLabDetail(editor, {
|
|
row: base?.logic ?? row,
|
|
file: base?.file ?? "",
|
|
reasons: base?.reasons ?? [],
|
|
prices: base?.prices ?? {},
|
|
lines: null,
|
|
text,
|
|
lastText,
|
|
values,
|
|
ownHost,
|
|
onChange: () => undefined,
|
|
onInput,
|
|
cover: { ho, open: lineOpener(hoCtx) },
|
|
});
|
|
side?.set([
|
|
...(base ? [tabOf(base.logic)] : []),
|
|
...extraKeys.flatMap((k) => (k === baseKey() ? [] : infoOf.has(k) ? [infoOf.get(k)!] : [])),
|
|
]);
|
|
}
|
|
|
|
/** 재료 고름(줄 모달) = SK 문서 `고름`(cid → MT) — 둘째 기초 세부 줄은 보기만 */
|
|
function pickMat(cid: string | undefined): ((it: { ref: string }) => void) | undefined {
|
|
if (!cid || cid.startsWith("add:#")) return undefined;
|
|
return (it) => {
|
|
doc.고름 = { ...(doc.고름 ?? {}), [cid]: it.ref };
|
|
touch();
|
|
};
|
|
}
|
|
|
|
/** 공용 상세 고침 고리 — 열쇠 = 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();
|
|
},
|
|
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 })] : [],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 옵션 칸(수식 탭 · 단가산출 상세) — 입력 고침 → 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);
|
|
}
|
|
void recalc(); // 750-2 옵션 바꿈 = 서버 셈 한 번 — 수식 · 단가산출 상세가 그 답 하나로 다시 그려짐
|
|
}
|
|
|
|
/** `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) 먼저 — 시험 계산 칸 항목(재료 잡힌 품목)이 쓰임
|
|
}
|
|
|
|
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 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
|
|
flush: async () => {
|
|
if (!tabs.dirty()) return;
|
|
if (!(await tabs.save())) throw new Error(tl("View_Equation"));
|
|
await refreshing; // 새 판 · `수식` 칸 받은 뒤 페이지가 저장
|
|
},
|
|
destroy: () => {
|
|
alive = false;
|
|
if (tabs.dirty()) void tabs.discard(); // 페이지가 버리고 떠남 — 수식 캐시도 비움
|
|
host.replaceChildren();
|
|
},
|
|
};
|
|
}
|