Files
Aislo/ui_template/ui_template_provenance.ts
T
eomsangdonandClaude Opus 5 c94e77734e fix(공용): 근거 호버 배지를 칸 등급으로 그리고 빠진 등급은 열에서 채움
같은 열이라도 줄마다 성격이 갈리는 자리가 있음 — 원가계산서 「금액」은 중간줄
(간접노무비 따위)이 계산인데 마지막줄(총원가·도급금액·총계)은 최종임(데스크탑 보조
B09 배선에서 나옴). `markProvenanceCell` 은 이미 칸마다 등급을 받고 있었으나
카드 배지가 열 등급으로만 그려져 **띠는 최종인데 배지는 「계산」**으로 어긋났음.

- `fill()` 이 칸을 받아 배지를 `cell.dataset.provTier || column.tier` 로 그림
- 등급을 안 심은 칸은 `attachProvenance` 가 열 등급으로 채움 — 안 그러면 배지는
  뜨는데 띠가 안 붙는 「색 없는 칸」이 생김. 부르는 쪽이 등급을 빼먹기 쉬운 자리라
  여기서 맞춤. 줄마다 다른 칸에만 세 번째 인자를 주면 됨.

자체검증(ORCA 5173) — 같은 열 `amount_krw` 에서 등급 안 심은 칸은 배지 「계산」·띠
초록 rgb(31,138,76), `final` 심은 칸은 배지 「최종」·띠 금색 rgb(184,134,11) 3px inset.
배지와 띠가 같은 말을 함. `tsc --noEmit` 통과 · 병합 뒤 `pytest -q` 1313 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 16:00:19 +09:00

288 lines
13 KiB
TypeScript

/* =============================================================================
* 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<string, ProvenanceColumn>;
}
/** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */
export interface ProvenancePayload {
sheets: Record<string, ProvenanceSheet>;
}
/** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */
const TIER_LABELS: Record<string, string> = {
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<HTMLElement>("[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<HTMLElement>("[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);
}