/* ============================================================================= * ui_template_provenance.ts * 표 칸에 마우스를 올리면 **그 숫자가 어디서 와서 어떻게 나왔는지**를 띄우는 한 벌. * B08 수량·B09 원가가 같이 쓴다 (PLAN 8-36 ②③). * * ⚠⚠ **개발 전용 — 사용자에게 보이지 않는다 (로직 보안).** * 사전은 서버가 개발환경에서만 실어 보낸다(`common_util_provenance.provenance_payload`). * 사전이 안 오면 이 모듈은 **아무것도 하지 않는다** — 화면에서 숨기는 것이 아니라 * 애초에 들고 있지 않은 것이 요점이다. 그래서 낱말도 번역하지 않는다(안 나간다). * * ⚠ 겉보기는 **칸 단위**지만 사전은 **열 단위**다. 칸마다 사전을 만들면 토적표 한 장이 * 6천 칸이라 응답이 붐는다. 줄마다 갈리는 것(폴백 사유 등)은 부르는 쪽이 * `resolveExtra` 로 얹는다. * ========================================================================== */ /** 열 하나의 사전. 서버 `ColumnProvenance.as_dict()` 와 1:1. */ export interface ProvenanceColumn { label: string; tier: string; formula?: string; source?: string; /** 고르는 자리의 채택 규칙(「안전관리비 A·B 중 작은 쪽」 따위). 없으면 칸이 안 뜨다. */ rule?: string; code?: string; } /** 한 장(시트)의 사전. */ export interface ProvenanceSheet { columns: Record; } /** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */ export interface ProvenancePayload { sheets: Record; } /** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */ const TIER_LABELS: Record = { input: "입력", survey: "측량", standard: "기준", calc: "계산", final: "최종", blocked: "막힘", excluded: "제외", unclassified: "미분류", }; /** 색칠을 켤지 — **포트별로 갈리는 sessionStorage** 에 둔다(창마다 취향이 다르다). */ const TINT_KEY = "aislo.provenance.tint"; const STYLE_ID = "ui-provenance-style"; const CARD_ID = "ui-provenance-card"; /** * 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. * * `tier` 를 주면 **그 칸만 열 등급을 이긴다.** 같은 열이라도 줄마다 성격이 갈리는 * 자리가 있기 때문이다 — 원가계산서 「금액」은 중간줄(간접노무비 따위)이 `calc` 인데 * 마지막줄(총원가·도급금액·총계)은 `final` 이다(2026-09-12 데스크탑 보조 B09 배선). * 칸 등급을 심지 않으면 열 등급이 그대로 선다. */ export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void { cell.dataset.provCol = columnKey; if (tier) cell.dataset.provTier = tier; } export function isProvenanceTinted(): boolean { try { return sessionStorage.getItem(TINT_KEY) === "on"; } catch { return false; } } function setTinted(root: HTMLElement, on: boolean): void { root.classList.toggle("is-prov-tinted", on); try { sessionStorage.setItem(TINT_KEY, on ? "on" : "off"); } catch { /* 저장이 막힌 창에서도 화면은 돌아야 한다 — 이번 화면에서만 켜진다. */ } } /** * 색칠 토글 단추. **사전이 없으면 만들지 않는다**(부르는 쪽이 `payload` 를 보고 거른다). * * ⚠ 여섯 색이 늘 켜져 있으면 표가 알록달록해 실무 시트와 눈으로 대조를 못 한다. * 그래서 **평소엔 꺼 두고** 이 단추로만 켠다. */ export function createProvenanceToggle(root: HTMLElement): HTMLElement { const button = document.createElement("button"); button.type = "button"; button.className = "ui-prov-toggle"; const paint = (): void => { const on = root.classList.contains("is-prov-tinted"); button.textContent = on ? "등급색 끄기" : "등급색 켜기"; button.classList.toggle("is-on", on); }; setTinted(root, isProvenanceTinted()); paint(); button.addEventListener("click", () => { setTinted(root, !root.classList.contains("is-prov-tinted")); paint(); }); return button; } function line(card: HTMLElement, name: string, value: string): void { if (!value) return; const row = document.createElement("div"); row.className = "ui-prov-card__row"; const key = document.createElement("span"); key.className = "ui-prov-card__key"; key.textContent = name; const body = document.createElement("span"); body.className = "ui-prov-card__value"; body.textContent = value; row.append(key, body); card.append(row); } function card(): HTMLElement { let element = document.getElementById(CARD_ID); if (!element) { element = document.createElement("div"); element.id = CARD_ID; element.className = "ui-prov-card"; document.body.append(element); } return element; } /** 카드를 마우스 옆에 둔다 — 화면 밖으로 나가면 반대쪽으로 접는다. */ function place(element: HTMLElement, x: number, y: number): void { element.style.visibility = "hidden"; element.style.display = "block"; const box = element.getBoundingClientRect(); const left = x + 16 + box.width > window.innerWidth ? x - 16 - box.width : x + 16; const top = y + 16 + box.height > window.innerHeight ? y - 16 - box.height : y + 16; element.style.left = `${Math.max(4, left)}px`; element.style.top = `${Math.max(4, top)}px`; element.style.visibility = "visible"; } /** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */ /** 카드 한 장을 채운다. 배지는 **칸 등급**을 먼저 본다 — 띄는 띄었는데 배지가 다른 말을 * 하면 읽는 사람이 둘 중 어느 것을 믿을지 모른다. */ function fill( target: HTMLElement, cell: HTMLElement, column: ProvenanceColumn, value: string, extra: string[], ): void { target.replaceChildren(); const head = document.createElement("div"); head.className = "ui-prov-card__head"; const title = document.createElement("span"); title.textContent = column.label; const badge = document.createElement("span"); badge.className = "ui-prov-card__badge"; const tier = cell.dataset.provTier || column.tier; badge.dataset.provTier = tier; badge.textContent = TIER_LABELS[tier] ?? tier; head.append(title, badge); target.append(head); line(target, "값", value); line(target, "식", column.formula ?? ""); line(target, "원천", column.source ?? ""); line(target, "채택", column.rule ?? ""); line(target, "자리", column.code ?? ""); for (const note of extra) line(target, "줄 사유", note); } /** * 표에 호버를 붙인다. 칸에 심어 둔 `data-prov-col` 로 사전을 찾는다. * * `resolveExtra` — 줄마다 갈리는 사유(폴백 안분 등)를 얹고 싶을 때 부르는 쪽이 준다. * 사전에 없는 열은 **아무 일도 안 한다** — 빈 카드를 띄우면 「설명이 있다」는 거짓이 남는다. */ export function attachProvenance( root: HTMLElement, sheet: ProvenanceSheet | undefined, resolveExtra?: (cell: HTMLElement, columnKey: string) => string[], ): void { if (!sheet) return; injectProvenanceStyles(); // 등급을 안 심은 칸은 **여기서 열 등급으로 채운다.** // 색칠은 CSS 가 `data-prov-tier` 를 보고 하므로, 그 칸은 배지만 띄고 띄는 안 붙어 // 「색이 안 붙는 칸」이 생긴다. 부르는 쪽이 등급을 빼먹는 것은 흔한 일이라 여기서 맞춘다. for (const cell of root.querySelectorAll("[data-prov-col]")) { if (cell.dataset.provTier) continue; const tier = sheet.columns[cell.dataset.provCol ?? ""]?.tier; if (tier) cell.dataset.provTier = tier; } const hide = (): void => { const element = document.getElementById(CARD_ID); if (element) element.style.display = "none"; }; root.addEventListener("mouseover", (event) => { const cell = (event.target as HTMLElement).closest("[data-prov-col]"); if (!cell || !root.contains(cell)) return; const column = sheet.columns[cell.dataset.provCol ?? ""]; if (!column) return hide(); const target = card(); fill( target, cell, column, cell.textContent?.trim() ?? "", resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [], ); place(target, (event as MouseEvent).clientX, (event as MouseEvent).clientY); }); root.addEventListener("mousemove", (event) => { const element = document.getElementById(CARD_ID); if (!element || element.style.display === "none") return; place(element, (event as MouseEvent).clientX, (event as MouseEvent).clientY); }); root.addEventListener("mouseleave", hide); root.addEventListener("mouseout", (event) => { const next = (event as MouseEvent).relatedTarget as HTMLElement | null; if (!next || !next.closest?.("[data-prov-col]")) hide(); }); } const CSS = ` /* 등급색 — 평소엔 꺼져 있고 토글로만 켠다. 칸 왼쪽 얇은 띠 + 아주 옅은 배경이라 숫자 읽기를 방해하지 않는다. 색은 테마 변수를 섞어 어두운 테마에서도 맞는다. */ .is-prov-tinted [data-prov-tier="input"] { box-shadow: inset 3px 0 0 var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); } .is-prov-tinted [data-prov-tier="survey"] { box-shadow: inset 3px 0 0 var(--color-info, #3b82f6); background: color-mix(in srgb, var(--color-info, #3b82f6) 7%, transparent); } .is-prov-tinted [data-prov-tier="standard"] { box-shadow: inset 3px 0 0 var(--color-text-secondary); background: color-mix(in srgb, var(--color-text-secondary) 7%, transparent); } .is-prov-tinted [data-prov-tier="calc"] { box-shadow: inset 3px 0 0 var(--color-success, #16a34a); background: color-mix(in srgb, var(--color-success, #16a34a) 7%, transparent); } .is-prov-tinted [data-prov-tier="final"] { box-shadow: inset 3px 0 0 var(--color-warning, #c08a3e); background: color-mix(in srgb, var(--color-warning, #c08a3e) 10%, transparent); } .is-prov-tinted [data-prov-tier="blocked"] { box-shadow: inset 3px 0 0 var(--color-danger, #dc2626); background: color-mix(in srgb, var(--color-danger, #dc2626) 8%, transparent); } .is-prov-tinted [data-prov-tier="excluded"] { box-shadow: inset 3px 0 0 var(--color-muted, #9ca3af); background: repeating-linear-gradient(135deg, transparent, transparent 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 10px); } .is-prov-tinted [data-prov-tier="unclassified"] { box-shadow: inset 3px 0 0 var(--color-border); } .ui-prov-toggle { font-size: 11px; padding: 2px 8px; color: var(--color-text-secondary); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pills, 999px); cursor: pointer; } .ui-prov-toggle.is-on { color: var(--color-accent); border-color: var(--color-accent); } .ui-prov-card { display: none; position: fixed; z-index: 9999; max-width: 26rem; padding: 8px 10px; font-size: 11.5px; line-height: 1.5; color: var(--color-text); background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: var(--radius-cards, 8px); box-shadow: 0 6px 18px rgb(0 0 0 / 18%); pointer-events: none; } .ui-prov-card__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-weight: 600; } .ui-prov-card__badge { flex: 0 0 auto; padding: 0 6px; font-size: 10.5px; font-weight: 500; border-radius: var(--radius-pills, 999px); border: 1px solid currentColor; } .ui-prov-card__badge[data-prov-tier="input"] { color: var(--color-accent); } .ui-prov-card__badge[data-prov-tier="survey"] { color: var(--color-info, #3b82f6); } .ui-prov-card__badge[data-prov-tier="standard"] { color: var(--color-text-secondary); } .ui-prov-card__badge[data-prov-tier="calc"] { color: var(--color-success, #16a34a); } .ui-prov-card__badge[data-prov-tier="final"] { color: var(--color-warning, #c08a3e); } .ui-prov-card__badge[data-prov-tier="blocked"] { color: var(--color-danger, #dc2626); } .ui-prov-card__badge[data-prov-tier="excluded"] { color: var(--color-muted, #9ca3af); } .ui-prov-card__row { display: flex; gap: 8px; } .ui-prov-card__key { flex: 0 0 2.4rem; color: var(--color-text-secondary); } .ui-prov-card__value { min-width: 0; white-space: pre-wrap; } `; export function injectProvenanceStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = CSS; document.head.append(style); }