/* ============================================================================= * M02_MasterTemplete_Hopyo_Relink.ts * 「이 공사 단가로 다시 잇기」(PLAN 705-2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 · 지금 이은 단가 · * 이음 후보(이름 · 규격 · 단위 맞춤 · 705-3 이음과 같은 짝) · 차이 → 줄마다 · 모두 골라 [바꿈] · [되돌리기]. * 후보 칸 누름 = 다음 후보(끝 = 그대로 · 이은 줄은 「풂」) · 고름 칸 누름 = 켬 · 끔 * 출처 셋 — 사전조합 여럿(`relinkKeys` · 서버가 이음 칸만 고쳐 씀 · 전후 1 단위 값) · * 열린 문서(`relinkDoc` · 문서에 넣고 [저장]) · B09 지금 호표(부르는 쪽 · 이 공사 단가 책) * 숫자는 서버 단가 책 값 — 화면은 이음 키만 고름 * ========================================================================== */ import { createButton, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { createDataTable, type DataRow } from "@ui/ui_template_data_table"; import { createText } from "@ui/ui_template_text"; import { pageScope, relinkApply, relinkRows, type RelinkChange, type RelinkRow, } from "./M02_MasterTemplete_Hopyo_Api"; import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; type Kind = "노임" | "자재"; const COST: Record = { 노임: "노무비", 자재: "재료비" }; export interface RelinkSource { title: string; /** 이은 단가 칸 머리 — 「최신 판 단가」 · 「이 공사 단가」 */ priceLabel: string; rows: () => Promise; /** 바꿈 → 되돌릴 바꿈 · 알림 글 */ apply: (changes: RelinkChange[]) => Promise<{ back: RelinkChange[]; note: string }>; /** 단가 덮기(B09 = 이 공사 단가 책) — 없으면 서버 값 */ price?: (kind: Kind, key: string) => number | null | undefined; } const won = (v: number | null | undefined): string => v == null ? "-" : Math.round(v).toLocaleString("ko-KR"); /** 사전조합 여럿(붙여넣기 · 실무자료 들여오기 뒤) — 서버가 쓰고 전후 1 단위 값 */ export function relinkKeys(keys: string[]): RelinkSource { const scope = pageScope(); return { title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`, priceLabel: "최신 판 단가", rows: () => relinkRows(scope, { 키: keys }), apply: async (changes) => { const got = await relinkApply(scope, changes); const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0); return { back: got.되돌림, note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, }; }, }; } /** 열린 문서(저장 전) — 이음을 문서에 넣고 `changed`(서버 셈 · [저장]) */ export function relinkDoc(doc: LaborDoc, changed: () => void): RelinkSource { const scope = pageScope(); return { title: "이 공사 단가로 다시 잇기 — 이 일위대가", priceLabel: "최신 판 단가", rows: () => relinkRows(scope, { 문서: doc }), apply: async (changes) => { const back: RelinkChange[] = []; for (const c of changes) { const row = doc.담은로직.find((r) => r.고정 && lineId(r) === c.줄); if (!row?.고정) continue; const link = { 노임: null, 자재: null, 기계: null, ...(row.고정.이음 ?? {}) }; back.push({ 키: c.키, 줄: c.줄, 노임: link.노임, 자재: link.자재 }); if ("노임" in c) link.노임 = c.노임 ?? null; if ("자재" in c) link.자재 = c.자재 ?? null; row.고정 = { ...row.고정, 이음: link }; } changed(); return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` }; }, }; } /** 다시 잇기 창 */ export async function openRelink(src: RelinkSource): Promise { let rows: RelinkRow[] = []; /** 줄마다 고른 후보 차례(후보 수 = 그대로 · 이은 줄의 +1 = 풂) · 켬 */ let pick: number[] = []; let on: boolean[] = []; let back: RelinkChange[] = []; const host = document.createElement("div"); const info = createText("", "muted"); const linked = (r: RelinkRow): boolean => !!(r.이음.노임 || r.이음.자재); const priceOf = (kind: Kind, key: string, given: number | null | undefined): number | null => src.price?.(kind, key) ?? given ?? null; const load = async (): Promise => { rows = await src.rows(); pick = rows.map((r) => (r.후보.length && !linked(r) ? 0 : r.후보.length)); // 저절로 고름 = 후보 하나 · 붙인 단가 있음(별도계상 0 원 · 후보 여럿은 사람이 고름) on = rows.map((r) => r.후보.length === 1 && !linked(r) && r.붙인[COST[r.후보[0].비목]] > 0); }; const choice = (i: number): RelinkRow["후보"][number] | "keep" | "free" => { const r = rows[i]; if (pick[i] < r.후보.length) return r.후보[pick[i]]; return pick[i] === r.후보.length ? "keep" : "free"; }; const changes = (): RelinkChange[] => rows.flatMap((r, i): RelinkChange[] => { const c = choice(i); if (!on[i] || c === "keep") return []; if (c === "free") return [{ 키: r.키 ?? "", 줄: r.줄, 노임: null, 자재: null }]; return [{ 키: r.키 ?? "", 줄: r.줄, [c.비목]: c.키 }]; }); const nowPrice = (r: RelinkRow): string => (["노임", "자재"] as Kind[]) .filter((k) => r.이음[k]) .map((k) => won(priceOf(k, r.이음[k] as string, r.지금[k]))) .join(" · ") || "손 단가"; const draw = (): void => { let gapSum = 0; const data: DataRow[] = rows.map((r, i) => { const c = choice(i); const cand = typeof c === "object" ? c : null; const price = cand ? priceOf(cand.비목, cand.키, cand.단가) : null; const gap = cand && price != null && r.수량 != null ? (price - r.붙인[COST[cand.비목]]) * r.수량 : null; if (on[i] && gap != null) gapSum += gap; return { key: `${r.키 ?? ""}|${r.줄}`, name: [r.이름, r.규격].filter(Boolean).join(" · "), unit: r.단위, qty: r.수량, pasted: r.붙인.노무비 + r.붙인.재료비 + r.붙인.경비, now: nowPrice(r), cand: cand ? `${cand.비목} ${cand.이름} ${cand.규격} (${pick[i] + 1}/${r.후보.length})` : c === "free" ? "이음 풂 — 붙인 단가로" : r.후보.length ? `그대로 (후보 ${r.후보.length})` : "후보 없음", price, gap, pick: on[i] ? "✓" : "", }; }); const table = createDataTable({ columns: [ { key: "name", label: "품명 · 규격", width: 190 }, { key: "pick", label: "고름", width: 44 }, { key: "unit", label: "단위", width: 44 }, { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 60 }, { key: "pasted", label: "붙인 단가", kind: "number", decimals: 1, width: 84 }, { key: "now", label: src.priceLabel, width: 84 }, { key: "cand", label: "이음 후보 (누름 = 다음)", width: 210 }, { key: "price", label: "후보 단가", kind: "number", decimals: 1, width: 84 }, { key: "gap", label: "금액 차이", kind: "number", decimals: 1, width: 84 }, ], rows: data, frozen: 1, empty: "", onCellClick: (i, key) => { const r = rows[i]; if (key === "cand") { pick[i] = (pick[i] + 1) % (r.후보.length + (linked(r) ? 2 : 1)); on[i] = choice(i) !== "keep"; } else if (key === "pick") on[i] = !on[i]; else return; draw(); }, cellClass: (i, key) => (key === "pick" && on[i] ? "is-edited" : null), }); const n = changes().length; info.textContent = `고정 줄 ${rows.length} · 후보 있는 줄 ${rows.filter((r) => r.후보.length).length} · ` + `이은 줄 ${rows.filter(linked).length} · 고른 줄 ${n} · 금액 차이 합 ${won(gapSum)}`; apply.disabled = !n; undo.disabled = !back.length; host.replaceChildren(table.root); }; const run = async (list: RelinkChange[], undoing: boolean): Promise => { try { const got = await src.apply(list); back = undoing ? [] : got.back; showToast(undoing ? `되돌림 — ${got.note}` : got.note, "success", 6000); await load(); draw(); } catch (error) { showToast(`실패 — ${error instanceof Error ? error.message : String(error)}`, "error"); } }; const all = createButton({ label: "모두 고름", variant: "ghost", onClick: () => { const want = !rows.every((r, i) => !r.후보.length || on[i]); on = rows.map((r, i) => (want ? !!r.후보.length && choice(i) !== "keep" : false)); draw(); }, }); const apply = createButton({ label: "바꿈", onClick: () => void run(changes(), false) }); const undo = createButton({ label: "되돌리기", variant: "ghost", onClick: () => void run(back, true), }); try { await load(); } catch (error) { showToast( `다시 잇기 표 실패 — ${error instanceof Error ? error.message : String(error)}`, "error", ); return; } openModal({ title: src.title, closeLabel: "닫기", wide: true, width: "1100px", actions: [all, undo, apply], mount: (body) => { body.append( createText( "붙인 단가 ↔ 단가 책 — 후보 칸을 눌러 고르고 [바꿈] · 품(수량)은 그대로 · 단가만 이음 · [되돌리기] = 바로 앞 바꿈", "muted", ), info, host, ); draw(); }, }); }