/* ============================================================================= * B09_Estimation_UI_Check.ts * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용). * * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 * 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → * 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] = * 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함 * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { openModal } from "@ui/ui_template_modal"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { dateTimeText } from "../common_util/common_util_time"; import { readState } from "../A00_Common/b_page_state"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { link: Link; tab: string; name: string; why: string; } const BLANK_WHY = "금액 모름 — 단가연결 칸 램프 · 이은 호표의 빈 줄을 확인"; /** 빈 줄(설계내역서 줄마다 하나 · 까닭 모음) · 그 밖 확인 줄(탭 · 키마다 하나) · 알림 글 */ export function checkItems( sheets: Row, payload: Row, ): { items: CheckItem[]; others: CheckItem[]; notes: string[] } { const why = new Map(); const order: string[] = []; const named = new Map(); for (const g of (sheets.gaps || []) as Row[]) { const id = `${g.tab}|${g.key}`; if (!why.has(id)) (why.set(id, []), order.push(id), named.set(id, g)); const list = why.get(id)!; if (g.why && !list.includes(String(g.why))) list.push(String(g.why)); } const item = (tab: string, key: string, name: unknown, reasons?: string[]): CheckItem => ({ link: { tab: tab as TabId, key }, tab: tabName(tab), name: String(name ?? ""), why: reasons?.length ? reasons.join(" · ") : BLANK_WHY, }); const items = blankRows(sheets).map((r) => item("bill", r.key, r.name, why.get(`bill|${r.key}`))); const others = order .filter((id) => !id.startsWith("bill|")) .map((id) => item(named.get(id)!.tab, named.get(id)!.key, named.get(id)!.name, why.get(id))); const notes: string[] = [...(payload.notes || [])]; const missing: string[] = payload.inputs?.missing || []; if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); return { items, others, notes }; } /** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ export function refreshTip(payload: Row | null): string { const made = payload?.book?.made; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const stale = payload?.stale; return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; } /** [업데이트] 미리 보기 글 줄(703-3) */ export function previewLines(got: Row): string[] { const copies: Row[] = got.사본 || []; const edition = got.노임판 || {}; return [ `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, `다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, `고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`, `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, ]; } /** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */ function attachPreview(refresh: HTMLElement, payload: () => Row | null): void { let pass = false; refresh.addEventListener( "click", (event) => { if (pass) return void (pass = false); event.stopImmediatePropagation(); const projectId = currentProjectId(); const user = readState("b09-estimation", projectId) ?? payload()?.user ?? {}; void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user }), }) .then(async (response) => { const got = (await response.json()) as Row; if (!response.ok) throw new Error(String(got?.detail ?? response.status)); openModal({ title: "업데이트 미리 보기", closeLabel: "닫기", width: "40rem", mount: (body, close, head) => { const apply = createButton({ label: "적용", variant: "filled", onClick: () => { close(); pass = true; refresh.click(); }, }); apply.dataset.b09RefreshApply = ""; head.append(apply); body.append(...previewLines(got).map((line) => createText(line))); }, }); }) .catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error")); }, true, ); } export interface CheckPanel { /** 묶음 안 연결 점검 자리 */ list: HTMLElement; draw: (payload: Row, sheets: Row) => void; } export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel { let payload: Row | null = null; let items: CheckItem[] = []; attachHoverInfo(refresh, () => refreshTip(payload)); attachPreview(refresh, () => payload); const box = document.createElement("div"); box.className = "b09-check"; attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); const head = (text: string): HTMLElement => { const p = createText(text, "muted"); p.classList.add("b09-check__head"); return p; }; const list = (group: CheckItem[], offset: number): HTMLElement => { const rows = document.createElement("div"); rows.className = "ui-sidebar-list"; rows.append( ...group.map((item, n) => { const row = document.createElement("button"); row.type = "button"; row.className = "ui-sidebar-row"; row.dataset.i = String(offset + n); const name = document.createElement("span"); name.textContent = item.name; const tab = createBadge(item.tab, "neutral", { size: 18 }); tab.classList.add("b09-check__tab"); row.append(name, tab); row.addEventListener("click", () => goto(item.link)); return row; }), ); return rows; }; const draw = (next: Row, sheets: Row): void => { payload = next; refresh.classList.toggle("is-pending", Boolean(next.stale)); const made = checkItems(sheets, next); items = [...made.items, ...made.others]; const blank = [ head(`빈 줄 ${made.items.length}`), ...(made.items.length ? [list(made.items, 0)] : []), ]; const others = made.others.length ? [head(`그 밖 확인 ${made.others.length}곳`), list(made.others, made.items.length)] : []; const notes = made.notes.map((text) => { const p = createText(text, "muted"); p.classList.add("b09-check__note"); return p; }); box.replaceChildren(...blank, ...others, ...notes); }; return { list: box, draw }; }