/* ============================================================================= * M02_MasterTemplete_Hopyo_Imported.ts * 불러오기 단산 줄 [가져온 식으로 열기] 모달(PLAN 793-3b · 요구 646 · 647 · 652 갈래 C) — * [수정] 모달 단산 줄(`Hopyo_Check`) · 오른쪽 목록 단가산출 탭 블록(`Hopyo_Block`)이 엶. * · 열기 = 서버 `…/sangeun/from-hopyo`(그 블록 → 가져온 식 SK · 만들 층 · 엑셀 단가 그대로 · 같은 내용 = 그 SK 다시 씀) * → 그 SK 를 읽어 우리 단가산출 화면(`Sangeun_Detail` 시트 · 고침 = 시트 그대로) + 머리 판(`Hopyo_ImportedPanel`) * · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}` * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) → * 블록 검산으로 입력 필요 · 단위 성격이 다르면 「환산 계수」도 입력 필요 * · [이음 보기](797-2) = 소요량 · 계수 후보 모달(`Hopyo_QtyLinks`) — 고르기 · 버리기 = 서버 `…/imported/{qty|qty-pick|qty-drop}`(기록만 · 금액 그대로) * · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) — * 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘 * ========================================================================== */ import { createButton } from "@ui/ui_template_button"; import { el, showToast } from "@ui/ui_template_elements"; import { createText } from "@ui/ui_template_text"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { askConfirm, openModal } from "@ui/ui_template_modal"; import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch"; import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { checkJudge, readJudgeBlock } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { importedPanel, tx, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; import { openQtyLinks, type QtyRow, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import { marksOf, skPick, type BasisCand, type ImportedDoc, type Money4, type Tier, } from "./logic/M02_Hopyo_Imported_Model"; import { bare, type DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; const TEXT = { Title: ["가져온 식 단가산출 — 단산{n}", "Imported build-up — block {n}"], Reused: ["같은 내용 {k} 를 다시 씀", "Reusing same content {k}"], Made: ["{k} 만듦(엑셀 단가 그대로)", "{k} made (Excel prices)"], Save: ["저장", "Save"], Saved: ["{k} 저장함", "{k} saved"], Link: ["이 줄에 잇기", "Use for this line"], Linked: ["단산 줄 = {k} — [확인] 뒤 [사전조합 생성]", "Line uses {k} — confirm, then build"], Discard: ["저장 안 한 고침이 있음 — 버리고 닫을까?", "Discard unsaved edits?"], Close: ["닫기", "Close"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); export interface ImportedOptions { /** 실무자료 파일 층 */ file: HopyoScope; key: string; /** 단산 블록 번 */ no: number; /** SK 를 만들 층 */ target: HopyoScope; canWrite: boolean; /** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */ onLink?: (pick: DraftPick) => void; } interface Made { 이름: string; 다시씀: boolean; } const fromHopyo = (o: ImportedOptions): Promise => call(at(o.target, "/sangeun/from-hopyo"), { method: "POST", body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore }); /** 기초 옵션 — 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본 · 근거 옵션) → 블록 검산 입력 필요 + 엇갈림 */ async function bind( o: ImportedOptions, code: string, given: Record, ): Promise<{ 입력값: Record; 필요: string[] }> { const blk = await readJudgeBlock(o.file, o.key, o.no, o.target).catch(() => null); const b = blk?.절.flatMap((s) => s.후보).find((c) => c.키 === code)?.바인딩; const values = { ...(b?.기본 ?? {}), ...(b?.입력값 ?? {}), ...given }; const pick = bare({ 갈래: "기초", 키: code, 이름: code, 입력값: values, 출처: {}, 몫: null }); const res = await checkJudge(o.file, o.key, { 블록: o.no, 고른: [pick] }); const need = [...(res.고른[0]?.입력필요 ?? []), ...Object.keys(b?.엇갈림 ?? {})]; return { 입력값: values, 필요: [...new Set(need)] }; } interface EditAnswer { 문서: ImportedDoc; 필요?: string[]; 전?: Money4 | null; 후?: Money4 | null; 이음없음?: { 자리: string; 이름: string }[]; /** qty 답(797-2) */ 줄?: QtyRow[]; 집계?: QtyTally; } export function openImported(o: ImportedOptions): void { const readOnly = !o.canWrite; const root = el("div", { className: "m02-hopyo", attrs: { "data-imported": `${o.key}|${o.no}` }, }); const top = el("div"); const host = el("div", { attrs: { "data-sk-body": "" } }); let sk = ""; let 판 = ""; let tier: Tier | null = null; /** 우리 기초로 바꾸기 전 가져온 식 · 고른 기초(환산 계수 넣을 때 다시 바꿈) */ let based: { src: ImportedDoc; code: string } | null = null; let dirty = false; let close = (): void => {}; let handle: ReturnType | null = null; const st: PanelState = { doc: {}, unlinked: [], qty: null, price: null, need: [], busy: false, readOnly }; // prettier-ignore const edit = (how: string, body: Record): Promise => call(at(o.target, `/sangeun/imported/${how}`), { method: "POST", body: JSON.stringify(body) }); function mount(doc: ImportedDoc): void { handle?.destroy(); st.doc = doc; handle = mountSangeunDetail(host, doc, { layer: o.target.layer, owner: o.target.owner, readOnly, onChanged: (d) => (dirty ||= d), }); draw(); } /** 환산 계수 없이는 못 셈 — 시트 안 엶(셈 길 400 없음) · 머리 판 붉은 칸만 */ function hold(doc: ImportedDoc): void { handle?.destroy(); handle = null; st.doc = doc; host.replaceChildren(createText(tx("NeedHint"), "warning", 14)); } const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc; function draw(): void { top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill, qty: openQty })); saveBtn.disabled = readOnly || st.busy; linkBtn.disabled = readOnly || st.busy; } /** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */ async function run( how: string, body: Record, from: ImportedDoc = now(), ): Promise { st.busy = true; draw(); try { const got = await edit(how, { 문서: from, ...body }); dirty = true; if (got.필요?.includes("환산")) hold(got.문서); else mount(got.문서); return got; } catch (e) { showToast(why(e), "error"); return null; } finally { st.busy = false; draw(); } } /** 이음 없는 자원 — 단가 기준 그대로 price 답(전 · 후 = 지금) */ async function unlinked(): Promise { if (!st.doc.가져온?.로직) return void (st.unlinked = []); const got = await edit("price", { 문서: st.doc, 기준: st.doc.가져온.단가기준 ?? "엑셀" }).catch(() => null); // prettier-ignore st.unlinked = got?.이음없음 ?? []; draw(); } /** 소요량 · 계수 이음 집계(797-2) — 가져온 식일 때만 */ async function qtyTally(): Promise { if (!st.doc.가져온?.로직) return void (st.qty = null); const got = await edit("qty", { 문서: st.doc }).catch(() => null); st.qty = got?.집계 ?? null; draw(); } function openQty(): void { openQtyLinks( { view: async () => { const got = await edit("qty", { 문서: now() }); return { 줄: got.줄 ?? [], 집계: got.집계! }; }, pick: async (spot, c) => { await run("qty-pick", { 자리: spot, 칸: { 표: c.표, 줄: c.줄, 칸: c.칸 } }); await qtyTally(); }, drop: async (spot, undo) => { await run("qty-drop", { 자리: spot, 되돌리기: undo }); await qtyTally(); }, }, readOnly, ); } async function price(basis: "엑셀" | "우리"): Promise { const got = await run("price", { 기준: basis }); if (got) st.price = { 전: got.전 ?? null, 후: got.후 ?? null }; draw(); } function link(spot: string, name: string): void { const done = (code: string): void => void run("link", { 자리: spot, 이음: code }).then(() => unlinked()); // prettier-ignore openFindPicker({ layer: o.target.layer, owner: o.target.owner, query: name, logic: (x) => done(`로직(${x.키})`), element: (e) => done(e.코드), machine: (e) => done(e.코드), oil: (e) => done(e.코드), }); } async function toBase(code: string, cand: BasisCand | null): Promise { st.busy = true; draw(); let bound: Awaited>; try { bound = await bind(o, code, cand?.옵션 ?? {}); } catch (e) { st.busy = false; draw(); return void showToast(why(e), "error"); } st.busy = false; const src = now(); const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }, src); if (!got) return; based = { src, code }; tier = cand?.단 ?? null; st.need = [...new Set([...bound.필요, ...(cand?.입력필요 ?? []), ...(got.필요 ?? [])])]; st.unlinked = []; st.qty = null; draw(); } function pickBase(): void { openFindPicker({ layer: o.target.layer, owner: o.target.owner, kinds: ["logic"], logic: (x) => void toBase(x.키, null) }); // prettier-ignore } /** 입력 필요 칸 — 옵션 = SK 입력값 · 「환산」 = SK 환산 계수 */ function fill(name: string, value: string): void { const doc = now(); const v = value.trim(); dirty = true; if (name === "환산" && based) { // 가져온 식에서 다시 바꿈(바꿈 전 · 후 = 서버 정본 · 옵션은 지금 값) const swap = v && Number(v) > 0 ? { 계수: Number(v) } : null; return void run("to-base", { 로직: based.code, 입력값: doc.입력값 ?? {}, 환산: swap }, based.src); // prettier-ignore } const kept = { ...(doc.입력값 ?? {}) }; if (v) kept[name] = v; else delete kept[name]; mount({ ...doc, 입력값: kept }); } async function save(): Promise { try { const doc = now(); const info = await saveTemplate(o.target.layer, "sangeun", sk, null, 판, doc, o.target.owner); 판 = info.판; st.doc = doc; dirty = false; showToast(t("Saved").replace("{k}", sk), "success"); return true; } catch (e) { showToast(why(e), "error"); return false; } } const saveBtn = createButton({ label: t("Save"), variant: "filled", onClick: () => void save() }); saveBtn.dataset.importedSave = ""; const linkBtn = createButton({ label: t("Link"), variant: "ghost", onClick: async () => { if (dirty && !(await save())) return; o.onLink!(skPick(sk, st.doc, st.need, marksOf(st.doc, tier))); showToast(t("Linked").replace("{k}", sk), "info"); dirty = false; close(); }, }); linkBtn.dataset.importedLinkRow = ""; linkBtn.hidden = !o.onLink; saveBtn.disabled = linkBtn.disabled = true; async function load(): Promise { const stop = showLoading(root); try { const made = await fromHopyo(o); sk = made.이름; showToast((made.다시씀 ? t("Reused") : t("Made")).replace("{k}", sk), "info"); const got = await readTemplate(o.target.layer, "sangeun", sk, null, o.target.owner); 판 = got.판 ?? ""; root.replaceChildren(top, host); mount(got.문서 as ImportedDoc); void unlinked(); void qtyTally(); } catch (e) { root.replaceChildren(loadingError(why(e), () => void load())); } finally { stop(); } } ({ close } = openModal({ title: t("Title").replace("{n}", String(o.no)), closeLabel: t("Close"), width: "1100px", wide: true, dialogClass: "m02-modal m02-hopyo__imported-modal", actions: [saveBtn, linkBtn], beforeClose: async () => { if (dirty && !readOnly && !(await askConfirm({ message: t("Discard"), danger: true }))) return false; handle?.destroy(); return true; }, mount: (body) => body.append(root), })); void load(); }