Google Sheets 연동 실시간 대시보드 구축 가이드 (TAB Dashboard 사례)
Properties3
description
End-to-end playbook for building a self-hosted, auto-refreshing HTML dashboard backed by a live Google Sheet (Tailwind + Chart.js UI, Node.js service-account data puller, externalized data file, Windows Task Scheduler business-hours automation, Cloudflare Pages deploy, trilingual i18n), using the TAB Live Dashboard build as the worked example and reusable checklist.
TAB Dashboard 구축 가이드, Google Sheets 커스텀 대시보드 만들기, 실시간 판매 대시보드 구축 가이드
92 min read
Google Sheets 연동 실시간 대시보드 구축 가이드 (TAB Dashboard 사례)
Key Insight
“Google Sheets가 바뀌면 대시보드도 자동으로 바뀐다”를 만드는 핵심은 하나의 소스오브트루스 HTML + 그 옆에 사는 별도 데이터 파일 + 그 데이터 파일만 주기적으로 재생성하는 얇은 Node 스크립트다. 대시보드 자체(레이아웃·차트·필터 로직)는 손으로/AI로 한 번 잘 만들면 거의 바뀌지 않고, 오직 데이터만 매시간 흐른다. 이 분리(UI 코드 vs 데이터 파일)가 없으면 매번 대시보드 전체를 다시 만들어야 한다.
이 문서는 2026-08-17~18에 05_SalesReport/TAB_Dashboard.html(TAB Live Dashboard)을 실제로 만든 전 과정을 재구성한 것이다. 이 문서 하나만 있으면, Power BI 모델이나 Google Sheets 구조가 바뀌었을 때 AI 에이전트에게 “이 가이드대로 새 대시보드를 만들어줘”라고 요청할 수 있도록, 디자인·기술·데이터 로직·자동화·보안까지 모든 결정과 이유를 남긴다.
Update (2026-09-03) — 서비스·부품판매·재고 탭이 같은 패턴에 합류
이 가이드가 처음 정리된 시점(2026-08-17~26) 이후 TAB_Dashboard.html에 서비스(TA-Service Log)·부품판매(Parts Sales)·재고(Stock/Serial, 2026-09-03 신설) 3개 탭이 추가됐고, 전부 이 문서의 패턴을 그대로 따른다 — 각자의 pull-*-dashboard-data.mjs 스크립트(pull-service-dashboard-data.mjs/pull-part-sales-dashboard-data.mjs/pull-stock-dashboard-data.mjs)가 rebuild-site.ps1의 매시간 파이프라인에 단계로 편입돼 있고(§4), 각 탭은 §2.2의 “자기 완결형 필터”(공유 사이드바 대신 패널 내부 필터바) 변형을 쓰며, §3의 i18n 절차를 그대로 따라 한/중/영 3개국어를 지원한다. 재고 탭은 처음엔 1회성 스냅샷+한국어 전용으로 시작했다가(서비스/부품판매와 동일한 “먼저 배포, 나중에 자동화” 경로) 같은 세션 내에서 자동동기화+3개국어로 승격됐다 — 이 경로 자체가 이 가이드 §8 체크리스트의 실전 사례다. §1.6(DMG 요약표 패턴)은 재고 탭 도입 후 실제로 대체됐다 — 아래 해당 절의 Update 참고.
사전 개발 히스토리 (v2~v9, 2026-08-17)
TAB_Dashboard.html은 이 문서가 다루는 “Google Sheets 실시간 연동” 작업 이전에, 별도 세션에서 이미 완성된 상태로 시작했다 — Power BI New Sales Dashboard v7을 픽셀·수치 단위로 재현하는 목표로 v2부터 v9까지 6라운드에 걸쳐 반복 개발됐다(2026-08-17-sales-dashboard-html-build-session-log 참고). 그 세션에서 나온 두 가지가 이 가이드에 직접 영향을 준다:
매출/수주 집계 규칙이 /ask 챗봇과 다르다 — 아래 §6의 각주 참고. 라이브 PBI와의 수치 패리티가 목표였기 때문에 의도적으로 다른 규칙을 쓴다.
graph TD
A["Google Sheets\n('Turbo Air' 스프레드시트)"] -->|"서비스 계정 OAuth2 (JWT-bearer)"| B["scripts/pull-sales-data.mjs\n(Node.js)"]
B -->|"재생성"| C["05_SalesReport/sales-data.js\n(SALES_ROWS + SALES_META + DAMAGE_DATA)"]
D["05_SalesReport/TAB_Dashboard.html\n(UI/로직 - 거의 안 바뀜)"] -->|"script src"| C
E["Windows Task Scheduler\nTAB-Wiki-Rebuild (평일 9~18시, 매시 정각)"] -->|"실행"| F["rebuild-site.ps1"]
F -->|"2.515 데이터 풀"| B
F -->|"2.516 복사"| G["public/dashboard/tab/"]
D -->|"복사"| G
C -->|"복사"| G
F -->|"quartz build + wrangler deploy"| H["Cloudflare Pages\nturboairbrain.uk/dashboard/tab"]
I["TAB Wiki 지금 동기화 (바탕화면)"] -->|"schtasks /run"| E
핵심 파일 5개 (전부 이번 세션에서 만들거나 수정):
파일
역할
얼마나 자주 바뀌나
05_SalesReport/TAB_Dashboard.html
UI + 차트 + 필터 로직 + i18n
거의 안 바뀜 (기능 추가할 때만)
05_SalesReport/sales-data.js
실제 데이터 (자동 생성)
매시간 (9~18시)
quartz-site/scripts/_google-sheets-auth.mjs
Google 인증 공용 모듈
거의 안 바뀜
quartz-site/scripts/pull-sales-data.mjs
시트→데이터파일 변환 스크립트
시트 컬럼 구조 바뀔 때만
quartz-site/rebuild-site.ps1
전체 파이프라인 오케스트레이션
새 페이지/단계 추가할 때만
1. 데이터 파이프라인 (기술적 요소 핵심)
1.1 왜 “라이브 API 호출”이 아니라 “주기적 배치 생성”인가
두 가지 방식이 가능했다:
(A) 브라우저가 매번 직접 Google Sheets API를 호출 — 실시간이지만, 브라우저에 API 키/서비스 계정 credential을 노출해야 해서 보안상 불가. Google Sheets 공개 CSV 게시 링크를 쓰는 방법도 있지만 판매 데이터 전체(딜러명·매출액)가 공개 URL이 되어버려 채택 안 함.
(B, 채택) 서버(로컬 스크립트)가 주기적으로 시트를 읽어 정적 데이터 파일을 재생성 → 그 파일을 정적 사이트에 같이 배포 — credential이 로컬/서버에만 존재하고 브라우저에는 절대 노출되지 않는다. 대신 “실시간”이 아니라 “최대 1시간 지연”이 된다 (9~18시 매시 정각).
Open Question
지연이 1시간이 아니라 몇 분 단위로 더 촘촘해야 한다면, Cloudflare Pages Function으로 /api/dashboard-data 같은 프록시 엔드포인트를 만들어 브라우저가 그걸 호출하게 하는 방식(§8 대안)으로 전환할 수 있다. 지금은 시간당 갱신으로 충분하다고 판단해 (B)를 택함.
1.2 Google 서비스 계정 인증 (JWT-bearer OAuth2)
같은 스프레드시트를 두 군데(① Cloudflare Worker의 /ask 봇, ② 이 로컬 데이터 풀 스크립트)에서 각각 읽는다. 두 곳은 서로 다른 실행 환경이라 자격증명을 공유할 수 없다:
로컬 JSON 키 파일 (quartz-site/turboair-brain-*.json, .gitignore 처리)
Task Scheduler로 도는 일반 프로세스라 파일 읽기가 자연스러움
같은 서비스 계정(tab-wiki-sheets-reader@{project}.iam.gserviceaccount.com)에 키를 2개 발급해서 각자 갖게 하면 된다 — Google Cloud Console → IAM & Admin → Service Accounts → 해당 계정 → KEYS 탭 → ADD KEY → Create new key(JSON). 여러 키가 동시에 유효할 수 있으므로 기존 Cloudflare 시크릿을 건드리지 않고 새 키를 추가 발급받는 게 제일 안전하다.
그 JWT를 grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer로 Google OAuth2 토큰 엔드포인트에 POST
받은 access_token(1시간 유효)을 Authorization: Bearer 헤더로 Sheets API에 사용
Node.js에서는 외부 라이브러리(googleapis, jsonwebtoken) 없이 **내장 crypto.createSign('RSA-SHA256') + 내장 fetch**만으로 전체 플로우를 구현할 수 있다(scripts/_google-sheets-auth.mjs 전체가 이 원리). Cloudflare Worker 쪽은 Web Crypto API(crypto.subtle.sign)로 동일한 일을 한다 — 알고리즘은 같고 API만 다르다.
export { getAccessToken, fetchRange, fetchSheetMetadata } from "./_google-sheets-auth.mjs";
// getAccessToken(keyFilePath) → JWT 서명 → 토큰 교환 → access_token 반환
// fetchRange(token, "Sales Data!A:AZ") → UNFORMATTED_VALUE + SERIAL_NUMBER로 원시 셀 배열 반환
// fetchSheetMetadata(token) → 스프레드시트의 전체 탭 이름 목록 반환 (탭 구조 탐색용)
1.3 시트 범위(Range) 선택 — 오늘 겪은 두 가지 함정
함정 1 — 행 개수를 상한으로 지정하지 말 것
Sales Data!A1:AA25000 처럼 행 번호를 박아두면, 시트가 그보다 커지는 순간(오래된 순으로 정렬된 시트라면) 최신 데이터가 조용히 잘려나간다. 실제로 /ask 봇에서 이 실수로 2026년 상반기 매출이 실제의 23~35%로 집계된 사고가 있었다(2026-08-14). 컬럼만 열린 범위로 지정(Sales Data!A:AZ)하면 Sheets API가 데이터 있는 모든 행을 한 번에 반환한다. 컬럼도 실제 쓰는 것보다 넉넉히(예상보다 10열 이상) 열어둬서, 나중에 시트에 컬럼이 하나 끼워 넣어져도 필요한 필드가 범위 밖으로 밀려나지 않게 한다.
함정 2 — 날짜는 FORMATTED_STRING이 아니라 SERIAL_NUMBER + UNFORMATTED_VALUE로
시트가 날짜를 호주식 DD/MM/YYYY로 표시하면, new Date("25/07/2026")은 일(day)이 25로 해석되며 월(month) 자리에 25가 들어가 즉시 Invalid Date가 된다 — day가 12 이하인 날짜만 우연히 파싱되고, 13일 이상인 날짜는 전부 조용히 버려진다. valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=SERIAL_NUMBER로 요청하면 날짜가 숫자(1899-12-30 기준 경과일수)로 오므로, new Date(Date.UTC(1899,11,30) + days*86400000)로 결정론적으로 변환할 수 있다. (체크박스 컬럼도 같은 옵션에서 실제 JS boolean으로 온다 — §1.5 참고.)
1.4 컬럼 매핑은 “위치”가 아니라 “헤더 이름”으로
TAB_Dashboard.html이 기대하는 20개 컬럼 순서(const C = { STATE:0, BRANCH:1, STATUS:2, ... })는 시트의 실제 컬럼 순서와 다르다(원본 시트는 No, State, Status, Order date, Order No, P/O No, Company, Invalid Check, Salesman, ... 순으로 39개+ 컬럼). pull-sales-data.mjs의 COLUMN_MAP은 매번 시트 1행(헤더)에서 이름으로 인덱스를 찾아 필요한 컬럼만 골라 원하는 순서로 재배열한다:
const COLUMN_MAP = [
["State", "STATE"], ["Branch", "BRANCH"], ["Status", "STATUS"], ["Order date", "ORDER_DATE"],
["Company", "COMPANY"], ["Salesman", "SALESMAN"], ["Model", "MODEL"],
["Inv Date", "INV_DATE"], ["Inv No", "INV_NO"], ["Delivery", "DELIVERY"],
["TA$ Exc", "TA_EXC"], ["RRP", "RRP"], ["DC rate", "DC_RATE"],
["Sales", "SALES"], ["Freight Cost", "FREIGHT_COST"], ["Freight", "FREIGHT"],
["Extra", "EXTRA"], ["TA$", "TA"], ["Damaged", "DAMAGED"], ["Clearance", "CLEARANCE"],
];
// 헤더에서 못 찾으면 조용히 넘어가지 않고 즉시 throw — 시트 구조가 바뀌었다는 신호를 놓치지 않기 위함
이렇게 하면 시트에 컬럼이 추가/삭제/재배열돼도(헤더 텍스트만 그대로면) 스크립트가 안 깨진다. 재사용 시: 새 대시보드를 만들 때는 이 COLUMN_MAP 배열과 대시보드 HTML의 const C = {...} 맵, 이 두 곳만 새 시트 스키마에 맞게 고치면 된다.
Key Insight (2026-08-19) — "복잡한 판별 로직 대신 시트에 필드를 하나 추가한다"
브랜치(NSW/VIC/QLD) 판별을 원래 Company 값을 정규식으로 패턴 매칭해서 추정했는데(turbo\s*air\s*queensland|ta\s*qld), David가 “브랜치별 계산이 너무 복잡하다”며 시트에 Branch 컬럼을 직접 추가해 해결했다 — 코드의 판별 로직을 더 정교하게 다듬는 대신, 애초에 판별이 필요 없도록 데이터 자체에 정답을 넣는 방향. 검증 결과 기존 정규식과 100% 일치했지만(§6 계산 규칙 참고), 유지보수성(사람이 시트에서 관리 vs 코드에 박힌 정규식) 관점에서 명백히 나은 선택이었다. 이후 isQld(row) 같은 판별 함수는 “새 정본 필드 우선 → 필드가 비어있으면 기존 로직으로 폴백”의 2단 구조로 다시 짰다(§6 “TA QLD 식별” 참고) — 필드가 100% 채워질 때까지는 폴백을 절대 없애면 안 된다(2026-08-19 기준 시트의 약 2.4% 행이 아직 공백, 그중 다수가 최근 데이터라 폴백 없이 배포했다면 “오늘의 현황” 탭이 즉시 회귀했을 것). 새 대시보드에서 “이 값 판별이 자꾸 헷갈린다/복잡하다”는 요구가 나오면, 코드를 더 영리하게 만들기 전에 **“시트에 필드를 하나 추가하면 안 되는가”**부터 검토할 것.
1.5 체크박스(Boolean) 컬럼 처리 — 오늘 실제로 겪은 버그
Contradiction (실제 발생한 버그, 2026-08-18 수정)
Google Sheets의 체크박스 셀은 UNFORMATTED_VALUE로 요청하면 진짜 JS boolean(true/false)으로 온다 — 단, 값이 아예 비어있던(한 번도 체크 안 해본) 셀은 빈 문자열/undefined로 온다. pull-sales-data.mjs의 변환 함수(cellToOutput)가 이 컬럼들을 BOOL_COLUMNS 목록에 넣지 않으면, 일반 문자열 처리 분기(String(raw))를 타서 true(boolean) → "true"(문자열)로 바뀐다. 대시보드 코드가 r[C.DAMAGED] === true처럼 엄격 비교를 하면 문자열 "true"는 boolean true와 절대 같지 않으므로 항상 0건으로 집계된다 — 실제로 “손상품 판매”·“클리어런스 판매” 카드가 계속 0을 보여준 원인이었다.
재사용 체크리스트: 새 시트에 체크박스/불리언 컬럼이 있다면 반드시 BOOL_COLUMNS(현재 ["TA$ Exc", "Damaged", "Clearance"])에 추가하고, 대시보드에서 boolean으로 엄격 비교(=== true)하는 모든 필드가 이 목록에 들어있는지 대조할 것.
function cellToOutput(raw, sheetHeader) {
if (raw === undefined || raw === "") return null;
if (DATE_COLUMNS.has(sheetHeader)) return serialToIso(raw);
if (BOOL_COLUMNS.has(sheetHeader)) {
if (typeof raw === "boolean") return raw;
return String(raw).trim().toUpperCase() === "TRUE"; // 문자열 "TRUE"로 올 때 대비
}
if (NUMBER_COLUMNS.has(sheetHeader)) { /* parseFloat */ }
return String(raw); // 그 외는 텍스트로
}
1.6 여러 탭을 같은 스프레드시트에서 함께 읽기 — DMG 요약 테이블 사례
손상재고(Damaged stock) 수치는 Sales Data 탭의 행 단위 데이터가 아니라, 같은 스프레드시트의 별도 Table 탭에 사람이 손으로 관리하는 작은 요약표(Branch | Damaged | ordered | Damaged Sale | Clearance Sale, NSW/VIC 딱 2줄)에서 가져온다. 한 스크립트 실행 안에서 fetchRange(token, "Table!A1:E3")처럼 두 번째 범위를 추가로 조회하면 된다 — 인증 토큰은 재사용 가능(1시간 유효).
이런 “일부는 행 단위 원본에서 재계산, 일부는 사람이 관리하는 소규모 스냅샷 표를 그대로 읽기”의 혼합 패턴은 흔하다. 재사용 시 반드시 확인할 것: 어떤 지표가 원본 데이터에서 계산 가능하고, 어떤 지표는 계산 로직이 불명확해 사람이 별도로 관리하는 요약표에 의존해야 하는지 — 이건 코드로 추측하지 말고 시트 소유자에게 직접 물어봐야 한다 (이번에도 “백로그는 계산해도 되지만 손상재고는 별도 표에서 가져와야 한다”는 걸 사용자에게 직접 확인받았다).
Update (2026-09-03) — 이 DMG 요약표 패턴은 재고 탭 도입 후 대체됨
재고 탭(Stock/Serial 시트) 신설로 “손상재고”를 원본 Stock 시트의 ND/VD 컬럼에서 직접 계산할 수 있게 되면서, pull-sales-data.mjs는 더 이상 Table!A1:E3(DMG 요약표)을 조회하지 않는다 — DAMAGE_DATA는 이제 백로그(계산 가능한 지표)만 담고, 대시보드의 손상재고 KPI(renderDamageCards())는 재고 탭과 동일한 원천인 STOCK_ROWS(stock-data.js)에서 직접 합산한다. David가 수기로 관리하던 DMG 시트 자체도 삭제 예정. 이 절이 남긴 교훈은 여전히 유효하다 — “계산 가능 vs 사람이 관리하는 요약표 의존” 판단은 시간이 지나 원본 데이터 커버리지가 넓어지면 후자에서 전자로 옮겨갈 수 있다는 사례로 참고할 것. (재고 탭 자체의 확정 로직: Stock 테이블 명세 및 데이터 카탈로그 §3.)
1.7 데이터를 HTML에서 분리하기 (externalize)
원래 TAB_Dashboard.html은 <script>const SALES_ROWS = [[...16,906행...]];</script>처럼 데이터를 인라인으로 통째로 품고 있었다 (파일 크기 1.5MB+, 매시간 파일 전체가 바뀌어 diff가 의미 없어짐). 다음과 같이 분리했다:
HTML에서 데이터 선언부를 지우고 <script src="sales-data.js"></script> 한 줄로 대체 (파일 크기 1.5MB → 92KB)
pull-sales-data.mjs가 sales-data.js(데이터 전용 파일, const SALES_ROWS/SALES_META/DAMAGE_DATA = ...)를 매시간 새로 씀
사이트 배포 시 두 파일을 함께public/dashboard/tab/로 복사(§4)
장점: (1) 대시보드 UI/로직 코드는 git으로 깔끔하게 버전 관리 가능, (2) 데이터 파일은 매번 통째로 재생성돼도 무방(diff 볼 필요 없음), (3) 브라우저 캐싱 관점에서도 코드와 데이터가 분리되면 유리하다.
2. 대시보드 UI/디자인 요소
2.1 기술 스택 (전부 CDN, 빌드 단계 없음)
라이브러리
버전/출처
역할
Tailwind CSS
cdn.tailwindcss.com
유틸리티 클래스 기반 레이아웃/스타일
Chart.js
chart.js@4.4.4 (jsDelivr)
라인/바 차트
chartjs-plugin-datalabels
2.2.0 (jsDelivr)
차트 위 숫자 라벨 직접 표시
Font Awesome
6.4.0 (cdnjs)
아이콘
Google Fonts (Inter)
fonts.googleapis.com
본문 서체
왜 CDN인가: 이 페이지는 단일 정적 HTML 파일로 완결돼야 하고(빌드 파이프라인이 없어도 즉시 브라우저에서 열림), Quartz 빌드 대상도 아니다(§4에서 rebuild-site.ps1이 파일 그대로 복사만 함). npm 패키지로 번들링하면 오히려 유지보수 부담이 커진다 — 이 정도 규모(1탭짜리 대시보드)에는 CDN이 적정 기술.
2.2 레이아웃 패턴
상단 sticky 헤더 + 필터바: 로고/제목 + 실시간 상태 배지(“Live DB Connected”) + 필터 초기화 버튼 + 7개 탭 버튼(오늘의 현황/실적 종합/연도별 비교/캐시백/리베이트 35%/리베이트 41%/랭킹 — “딜러 딥다이브”는 2026-08-21 “캐시백”으로 개명, §6 참고), 그 아래 기간 단위(연/분기/월/주/일 — 분기는 2026-08-21 추가, §9.9)·연도범위·월범위(또는 분기범위) 필터가 같은 sticky 블록 안에 있음.
좌측 sticky 사이드바: 오늘의 현황(당일 수주/출고 NSW·VIC·QLD, 지역 필터 선택에 따라 브랜치별 행이 동적으로 표시/숨김됨), 지역(Branch)/거래처/거래처 영업사원(2026-08-21 추가, 캐시백 탭에서만 표시 — 우리 회사 영업사원과 구분하기 위한 명칭)/모델 필터, 리베이트 구간표(선택된 탭에 따라만 표시). “TA QLD 제외” 토글은 2026-08-19에 삭제됨 — 지역(Branch) 필터 자체가 5종(NSW+VIC+QLD/NSW+VIC/NSW/VIC/QLD)으로 QLD 포함 여부까지 함께 결정하므로 별도 토글이 불필요해짐. State vs Branch 역할 분리는 영업판매실적 조회 절차 §5-a 참고.
KPI 카드: 흰 배경 카드, 아이콘 배지(색상으로 카테고리 구분: indigo=수주, emerald=출고, blue=매출, amber=리베이트/기타), hover 시 translateY(-2px) + 그림자 강조.
탭 패널: 6개 탭 각각 <section class="tab-panel">으로 존재하되 active 클래스만 보이게 토글(전부 DOM에 미리 렌더링돼 있고 display만 전환 — SPA 라우팅 없음).
색상 체계: NSW=파랑(#2563eb), VIC=주황(#f97316) 고정 배색으로 지역 비교 차트 전체에서 일관 사용. 연도별 비교는 YEAR_PALETTE(파랑/주황/초록/빨강/보라/청록/황토 7색 순환)로 구분.
2.3 사이트 통합 시 반드시 지켜야 할 규칙 (오늘 겪은 버그로 확정)
TAB의 다른 페이지들(/ask, /dashboard/powerbi, /feedback 등)과 이 페이지가 같은 사이트 안에서 하나처럼 보이려면 다음 패턴을 그대로 따라야 한다:
공통 docnav 바: 모든 페이지 최상단에 <nav class="docnav">← Home · ← 대시보드 목록 [언어전환]</nav> 동일 패턴. data-router-ignore 속성 필수(Quartz의 SPA 네비게이션이 정적 페이지 링크를 가로채지 못하게 막음).
sticky 요소가 여러 개 겹칠 때는 top 값을 절대 하드코딩하지 말 것.docnav(사이트 공통 바) 위에 이 페이지 자체의 stickyTopBar(헤더+필터바)가 추가로 sticky이면, stickyTopBar의 top은 0이 아니라 docnav의 실제 높이여야 한다. 안 그러면 두 sticky 요소가 같은 y=0 자리를 두고 겹쳐서 필터바가 스크롤 중 사라지거나 깨진다(2026-08-18 실제 발생·수정한 버그). 해결 패턴:
// CSS: 고정값 대신 CSS 변수 + 안전한 기본값
#stickyTopBar { position: sticky; top: var(--docnav-h, 40px); }
// JS: 실제 렌더된 높이를 매번 측정해서 변수에 반영 (로드 시 + resize 시 + 언어전환 시)
function syncStickyOffsets() {
const docnavH = document.querySelector('.docnav').offsetHeight || 40;
document.documentElement.style.setProperty('--docnav-h', docnavH + 'px');
// 그 아래 또 sticky인 사이드바가 있다면 top = docnavH + barH + 여백 도 같이 갱신
}
하드코딩된 px 대신 항상 실측하는 이유: 언어가 바뀌면 텍스트 길이가 달라져 줄바꿈이 일어날 수 있고, 폰트 로딩 타이밍에 따라 높이가 미세하게 달라질 수 있기 때문.
하나의 공유 i18n 엔진(/i18n.js)을 모든 페이지가 같은 방식으로 로드 — §3 참고.
2.4 대시보드 “허브(선택 페이지)” 디자인 패턴
대시보드가 2개 이상(TAB 자체 제작 + Power BI)이 되면서, /dashboard를 곧바로 어느 한쪽으로 보내지 않고 선택 허브 페이지를 새로 만들었다(static-pages/dashboard.html). 패턴:
2개(또는 N개)의 큰 카드(.dash-card)를 그리드로 배치, 각 카드에 아이콘·제목·부제·설명 문단·체크마크 특징 리스트 3개·“열기 →” 버튼.
실시간/자동갱신되는 쪽에는 펄스 애니메이션이 있는 “LIVE · 매시간 갱신” 배지를 우상단에 별도로 얹어 차별화.
카드 하나를 클릭하면 /dashboard/{slug}로 이동(예: /dashboard/tab, /dashboard/powerbi) — URL 구조를 “허브가 부모, 실제 대시보드가 자식 경로”로 설계해두면 대시보드 개수가 늘어도 패턴이 깨지지 않는다.
3. 다국어(i18n) 처리
이 사이트는 사이트 전역에서 공유하는 클라이언트 사이드 엔진 하나(static-pages/i18n.js, window.TabI18n)를 쓴다. 대시보드처럼 큰 페이지에 새로 적용할 때의 절차:
페이지 상단에 window.I18N = { ko: {...}, zh: {...}, en: {...} } 사전을 정의 (이 스크립트 다음에 <script src="/i18n.js"></script>를 로드).
정적 마크업은 data-i18n="key"(textContent), data-i18n-html="key"(<br> 등 태그가 섞인 경우 innerHTML), data-i18n-placeholder="key"(input placeholder)로 표시 — TabI18n.applyLang()이 로드 시 자동으로 전부 치환한다.
JS가 매번 다시 그리는 동적 텍스트(차트 제목, 툴팁 단위, 페이지네이션 “이전/다음”, 연도·월 드롭다운 옵션, 템플릿 문자열 등)는 data-i18n으로 커버되지 않는다 — 그 자리에서 직접 TabI18n.t('key')를 호출하도록 렌더 함수를 고쳐야 한다.
언어가 바뀌면 동적 콘텐츠도 다시 그려야 한다: document.addEventListener('tab-lang-changed', () => { /* 드롭다운 재생성 + 현재 탭 재렌더 */ })로 훅을 건다.
사전 검증: 사용된 모든 data-i18n*/T() 키가 사전에 실제로 정의돼 있는지, 반대로 정의만 되고 안 쓰이는 키는 없는지 스크립트로 대조한다(정규식으로 data-i18n(-html|-placeholder)?="..." 와 T\('...'\) 를 전부 추출해 사전 키 집합과 diff) — 이번에 116개 키를 전부 1:1로 맞춘 뒤 배포했다.
설계 팁: 같은 한국어 문구가 페이지 여러 곳(예: 3개 탭 모두에 있는 “수주건수” KPI)에 반복되면 키를 재사용하면 된다 — data-i18n은 페이지당 유일할 필요가 없고, 같은 키를 가진 모든 요소가 한 번에 치환된다.
4. 사이트 배포 통합 (rebuild-site.ps1)
이 사이트(turboairbrain.uk)는 Quartz가 모르는 “손으로 만든 정적 페이지”들(ask/dashboard/feedback/reports/answers 등)을 빌드 후 public/에 그대로 복사해 넣는 패턴을 쓴다(Cloudflare Pages Function + Claude API로 Wiki 기반 Q&A 봇 만들기 §7 참고). TAB Dashboard도 같은 패턴을 따르되, 다른 볼트(05_SalesReport)에서 소스 파일을 가져온다는 점이 다르다:
# 2.515 - 데이터부터 새로 받는다 (다른 복사 단계보다 먼저)node scripts/pull-sales-data.mjs # 05_SalesReport/sales-data.js 재생성# 2.516 - 소스 오브 트루스(다른 볼트) → 사이트 public 폴더로 복사Copy-Item "$vaultPath\..\05_SalesReport\TAB_Dashboard.html" "public\dashboard\tab\index.html"Copy-Item "$vaultPath\..\05_SalesReport\sales-data.js" "public\dashboard\tab\sales-data.js"# 2.52 / 2.521 - 허브 페이지 + Power BI 페이지(이 저장소 소속, static-pages/)Copy-Item "static-pages\dashboard.html" "public\dashboard\index.html"Copy-Item "static-pages\dashboard-powerbi.html" "public\dashboard\powerbi\index.html"
재사용 시 새 대시보드를 하나 더 추가한다면: 위와 같은 3줄짜리 블록(데이터 풀 → 소스 복사 → 배포)을 rebuild-site.ps1에 새 단계 번호(예: 2.517~)로 추가하고, 허브 페이지(static-pages/dashboard.html)에 카드 하나를 더 넣으면 된다.
5. 자동화 스케줄링 (Windows Task Scheduler)
작업 이름
주기
하는 일
TAB-Wiki-QuerySync
24시간, 10분마다
/ask·/feedback의 KV 큐를 볼트로 즉시 동기화만(가벼움, 전체 빌드/배포 없음)
TAB-Wiki-Rebuild
매일 9:00~18:00, 매시 정각
데이터 풀 + 볼트 미러 + Quartz 빌드 + 전체 페이지 복사 + Cloudflare 배포(무거움)
TAB-Wiki-Rebuild를 “24시간 상시 매시간”에서 “업무시간(9~18시)만”으로 좁힌 이유: 대시보드 데이터는 업무시간에만 의미 있게 바뀌고, 새벽 배포는 자원 낭비이기 때문. TAB-Wiki-QuerySync는 그대로 24시간 10분 주기를 유지하므로 방문자 Q&A/피드백 캡처에는 영향이 없다(무거운 작업과 가벼운 작업을 애초에 분리해둔 덕).
PowerShell로 트리거 설정 (9시 시작, 1시간마다, 9시간 1분 동안 반복 = 9,10,…,18시 총 10회):
Duration을 정확히 9시간이 아니라 9시간 1분으로 준 이유: Task Scheduler의 반복 종료 판정이 “duration이 지나기 직전”에 걸쳐 있으면 마지막 회차(18:00 정각)가 누락될 수 있다. 1분 여유를 더 주면 확실히 포함된다.
수동 즉시 동기화: 바탕화면의 TAB Wiki 지금 동기화.vbs(→ sync-now.ps1 → schtasks /run TAB-Wiki-Rebuild)를 더블클릭하면 스케줄을 기다리지 않고 바로 전체 파이프라인(데이터 갱신 + 사이트 배포)이 돈다.
6. 데이터 로직 / 비즈니스 규칙 (TAB 특화 — 새 대시보드에도 “이런 게 있는지 물어봐야 한다”는 체크리스트로 재사용)
이 규칙들은 코드로 추측한 게 아니라 전부 사용자(David/Nathan)에게 직접 확인받은 것이다. 새 대시보드를 만들 때 반드시 시트/모델 소유자에게 같은 종류의 질문을 해야 한다:
Disputed Claim — 이 §의 "매출 인식 규칙"은 /ask 챗봇 기준이지, TAB_Dashboard.html 자체의 기준이 아니다 (미해결)
아래 첫 항목(화이트리스트: Inv No 발행 + Status∈{Credit,Invoiced,Confirmed})은 functions/api/_google-sheets.js(/ask 챗봇의 라이브 매출 집계) + 영업판매실적 조회 절차가 “Power BI Sales/Oders 팩트 테이블과 동일”하다고 명시하는 규칙이다. 반면 TAB_Dashboard.html의 매출/수주 KPI 자체는 블랙리스트 규칙(isSalesTableRow(): Status가 빈값·Cancelled·Hold가 아니면 Inv No 유무와 무관하게 전부 매출/수주로 집계)을 쓴다 — 개발 당시 라이브 Power BI New Sales Dashboard v7 리포트와 정확히 수치가 일치하는 것이 목표였고, HWD 딜러 Rebate(5%, 2026)=$12,972라는 실측값으로 이 블랙리스트 규칙을 검증했다(2026-08-17 개발 세션, 2026-08-17-sales-dashboard-html-build-session-log). 2026-08-18의 Google Sheets 실시간 연동 작업은 데이터 소스만 바꿨을 뿐(정적 임베드 → sales-data.js 외부 파일) 이 집계 규칙 자체는 건드리지 않았다.
아직 풀리지 않은 질문: “Sales/Oders 팩트 테이블은 화이트리스트”라는 서술과 “New Sales Dashboard v7 리포트는 블랙리스트로 실측 검증됨”이라는 서술이 동시에 참이려면, 같은 .pbix 안에서 그 리포트가 팩트 테이블과 다른 DAX measure를 참조하고 있어야 한다 — 확인되지 않은 가설이다. 다음에 Power BI 파일을 열 기회가 있으면 대조해 disputed 상태를 해소할 것. 그때까지는 turboairbrain.uk/ask에게 물어본 매출과 /dashboard/tab이 보여주는 매출액이 다를 수 있다는 점을 사용자에게 명확히 알려야 한다. 상세 경고: 영업판매실적 조회 절차 “계산 규칙” §.
매출 인식 규칙 (/ask 챗봇 기준, 화이트리스트): Inv No가 발행되고 Status가 {Credit, Invoiced, Confirmed} 중 하나인 행만 매출로 집계, 금액은 Inv Date 기준 월/연에 귀속.
매출 인식 규칙 (TAB_Dashboard.html 기준, 블랙리스트): Status가 {빈값, Cancelled, Hold}가 아니면 Inv No 유무와 무관하게 전부 매출로 집계 — 라이브 Power BI 모델과 동일 규칙.
수주(주문) 집계 규칙: 매출과 별개 지표. Order date 기준, Status가 {빈값, Cancelled, Hold}가 아닌 모든 행 포함(Restocked도 포함) — 아직 인보이스 안 된 주문까지 잡히므로 수요의 선행지표. (이 규칙은 두 시스템이 동일.)
관계사 제외 규칙: Company 필드에 특정 패턴(turbo\s*air\s*queensland|ta\s*qld)이 있으면 별도 관계사로 분류, “제외” 필터로 경영 수치에서 뺄 수 있게.
손상재고 규칙: 행 단위가 아니라 별도 요약표(§1.6)에서 State별 숫자를 그대로 가져옴 → 2026-09-03부터: 재고 탭의 Stock 시트(ND/VD 컬럼)에서 직접 합산 — §1.6 Update 참고.
손상품/클리어런스 판매 규칙: 출고(매출 인식)된 행들 중 Damaged/Clearance 체크박스 컬럼이 TRUE인 행의 건수(§1.5 버그 수정 참고).
리베이트 구간표(Tier scheme, TAD 프로그램): 출고건수(Restocked 제외) 구간별로 지급율이 계단식으로 오르는 세 가지 스킴(35%/41%/45% 트랙, 45%는 2026-08-21 신설 — 1-50/51-100/101-150 전부 0%, 151+만 2%), RRP 합계 × 지급율로 예상 리베이트 계산. (예전엔 이 구간 기준을 “매출건수”로 잘못 표기했었다 — 2026-08-21 정정. “매출건수”라는 표현·공식 자체를 이후로는 쓰지 않고 수주건수/출고건수 두 가지로만 구분한다.) 탭 통합(2026-08-21): 원래 “리베이트 35%”/“리베이트 41%” 별도 탭 2개였으나, 스킴이 3개로 늘면서 탭을 하나(“리베이트”)로 합치고 스킴은 탭 안의 필터(35%/41%/45% 버튼)로 전환 — Scheme 구간표·지급율 버튼·KPI가 선택에 따라 다시 계산되고, 스킴별 지급율 선택은 서로 독립적으로 기억된다. 거래처/기간 필터·추이차트·거래내역 테이블은 스킴과 무관하게 공유(원래도 두 탭이 같은 데이터를 썼음). KPI 박스도 “출고건수” 단독에서 “출고건수 / RRP 제외 건수”(TA Exc=TRUE 건수 병기)로 확장, 거래 내역 테이블 아래에 TA Exc=TRUE 레코드만 모은 별도 테이블을 추가했다.
캐시백(Cashback) — HWD 전용 별도 정책, TAD 리베이트와 다른 프로그램: “딜러 딥다이브”(2026-08-21 탭 제목을 “캐시백”으로 개명) 탭은 원래 HWD-Hospitality World Direct 한 곳에 지급하는 캐시백을 보기 위해 만들어졌다 — 거래처 영업사원 대상으로 매출액의 3%를 분기별 지급. 위 TAD 35%/41% 구간표(RRP 기반, 순 출고건수 tier)와는 산정 기준·지급 대상·주기가 전부 다른 완전히 별개의 프로그램이라 용어도 Rebate/리베이트가 아닌 Cashback/캐시백으로 분리했다. 거래처(Company) 필터를 남겨둔 이유는 이 정책이 추후 다른 거래처로 확장될 가능성이 있어서다(현재는 UI상 어느 거래처든 선택 가능하지만 실제 정책 대상은 HWD뿐). 지급율 옵션은 Cashback(5/4/3/2/1%) 5단계, 기본값 3%.
서비스 계정 키는 절대 git에 커밋하지 않는다 — .gitignore에 turboair-brain-*.json 패턴 등록 확인.
같은 credential을 두 실행 환경에 복제하는 대신, 같은 서비스 계정에 키를 여러 개 발급하는 방식을 택했다(§1.2) — 한쪽 키가 유출/폐기돼도 다른 쪽에 영향 없음.
사이트 전체가 Cloudflare Access(Basic Auth)로 보호돼 있으므로 대시보드 페이지도 별도 인증 없이 그 안에 들어간다. Power BI iframe 페이지는 “게시된 공개 URL을 iframe으로 감싸는 것 = 진짜 보안이 아니다”(URL을 알면 누구나 접근 가능, 단지 안 알려졌을 뿐)라는 한계가 있음을 인지하고 있어야 한다 — TAB Dashboard(자체 제작)는 데이터 자체가 사이트 인증 뒤에만 존재하므로 이 한계가 없다.
8. 새 대시보드를 만들 때 체크리스트 (재사용 핵심)
Power BI 모델이나 Google Sheets 구조가 바뀌었을 때, 또는 완전히 새로운 데이터소스로 비슷한 대시보드를 만들고 싶을 때 이 순서를 따른다:
소스 확인: 어떤 스프레드시트/탭에서 읽을 것인가? 헤더 이름은? 몇 행 정도인가? 체크박스(boolean) 컬럼이 있는가? 사람이 손으로 관리하는 별도 요약표가 필요한 지표가 있는가?(§1.6)
비즈니스 규칙 인터뷰: §6과 같은 형태로 “이 지표는 정확히 어떤 조건으로 세나요?”를 하나하나 사용자에게 확인 — 절대 추측하지 않는다.
서비스 계정 재사용 또는 신규 발급: 기존 tab-wiki-sheets-reader 계정에 그 시트에 대한 Viewer 권한을 추가하거나(가장 간단), 새 프로젝트라면 새 서비스 계정 발급.
pull-sales-data.mjs를 복제해 새 스크립트로: COLUMN_MAP·DATE_COLUMNS·BOOL_COLUMNS·NUMBER_COLUMNS·집계 로직을 새 스키마에 맞게 수정. _google-sheets-auth.mjs는 그대로 재사용(스프레드시트 ID만 바뀜).
UI는 Tailwind + Chart.js 조합으로 단일 HTML 파일 — §2 패턴(sticky 헤더, KPI 카드, 탭 네비게이션)을 그대로 뼈대로 삼는다.
데이터 externalize: 처음부터 <script src="{name}-data.js"> 패턴으로 설계(인라인으로 시작했다가 나중에 분리하는 것보다 처음부터 분리하는 게 낫다).
docnav + sticky 오프셋 규칙(§2.3)을 그대로 적용 — 사이트의 다른 페이지와 크롬이 다르면 안 됨.
i18n 사전 작성(§3) — 정적/동적 텍스트를 나눠 키를 매기고, 사용된 키와 정의된 키를 스크립트로 대조.
rebuild-site.ps1에 3단계 추가(§4): 데이터 풀 → 소스 복사 → (필요하면 허브 페이지에 카드 추가).
배포 후 실제로 열어서 확인 — 특히 boolean/체크박스 필드가 있다면 0건으로 나오지 않는지, 스크롤 시 헤더가 겹치지 않는지 직접 확인(둘 다 이번에 실제로 겪은 버그).
9. 차트 제작 디테일 (Chart.js 재사용 패턴 모음)
TAB_Dashboard.html의 모든 차트(라인/바)는 아래 공통 헬퍼·플러그인·규칙을 통해 만들어진다. 새 대시보드에서 차트를 새로 짤 때는 이 §를 그대로 뼈대로 삼는다 — 값만 바뀌고 패턴은 거의 재사용된다.
9.1 동적 차트 제목 — “지금 어떤 필터가 걸려있는지” 제목에 항상 드러내기
Key Insight
차트 위에 필터 드롭다운(예: 월별/누적YTD, 매출액/건수)이 있으면, 드롭다운의 값과 차트 제목이 시각적으로 분리돼 있어서 사용자가 헷갈린다 — “월별 매출 실적”이라는 고정 제목만 보고는 지금 매출액 기준인지 건수 기준인지 알 수 없다. 실제로 이 문제가 두 가지 형태로 터졌다: (1) JS 기본값과 <select>의 시각적 기본값이 어긋나 차트가 다른 데이터를 그리는데 제목은 그대로인 버그, (2) 필터를 바꿔도 제목이 안 바뀌어서 사용자가 “이 차트가 지금 뭘 보여주는거지”를 매번 드롭다운을 다시 확인해야 하는 UX 문제. 해결책은 제목 자체에 현재 필터값을 회색 보조 텍스트로 항상 이어붙이는 것 — 필터를 안 봐도 제목만으로 무엇을 보고 있는지 알 수 있게 한다.
재사용 시 반드시 지킬 규칙: JS의 필터 기본값(let xxxMetric = 'sum' 같은 초기값)과 HTML <select>의 selected 속성은 항상 같은 옵션을 가리켜야 한다. 브라우저는 selected 속성이 없으면 항상 첫 번째 <option>을 시각적으로 보여주므로, JS 기본값이 그 첫 옵션과 다르면 “화면엔 A라고 써 있는데 실제로 그려지는 건 B” 버그가 생긴다 — 옵션을 추가/순서 변경할 때마다 이 짝을 대조할 것.
패턴: 캡션을 두 개의 <span>으로 쪼갠다 — 하나는 고정 제목(i18n 사전 키), 하나는 필터 선택값을 반영하는 빈 <span id="...Suffix">. 렌더 함수가 매번 그 suffix span의 텍스트를 갱신한다.
번역 문제를 공짜로 해결: T(orderModeLabelKey)가 이미 존재하는 i18n 사전 키(드롭다운 옵션 텍스트와 동일한 키)를 재사용하므로, 새 번역 키를 따로 만들 필요가 없고 언어 전환 시 드롭다운과 제목이 항상 같은 단어로 일치한다.
tab-lang-changed 훅에 자동으로 올라탐: 언어 전환 이벤트가 이미 renderYrCompareCharts()(또는 해당 탭의 렌더 함수) 전체를 다시 부르도록 배선돼 있으므로(§3의 4번 규칙), suffix 갱신 코드를 별도로 언어전환 훅에 추가할 필요가 없다 — 렌더 함수 안에만 넣으면 끝.
필터 변경 이벤트도 동일: <select>의 change 리스너가 이미 같은 렌더 함수를 재호출하므로 마찬가지로 별도 배선이 필요 없다.
재사용 체크리스트: 필터가 딸린 차트를 새로 추가할 때마다 (1) 제목을 캡션 span + suffix span으로 분리, (2) suffix 갱신 한 줄을 렌더 함수 안에 추가, (3) JS 기본값 ↔ <select selected> 옵션 일치 확인 — 이 3단계만 지키면 “필터-제목 불일치” 버그 클래스 자체가 원천 차단된다.
9.2 마우스 오버 시 세로 보조선 (Crosshair) — Chart.js 커스텀 플러그인
Chart.js 기본 툴팁은 데이터 포인트에 값을 보여주지만, 여러 라인이 겹친 차트에서 지금 마우스가 x축 어느 지점을 가리키는지 시각적으로 짚어주는 세로선이 없다. 아래 커스텀 플러그인을 한 번만 등록해두면 모든 라인차트에 공짜로 적용된다:
const crosshairPlugin = { id: 'crosshairPlugin', afterDraw(chart) { if (chart.config.type !== 'line') return; // 라인차트에만 적용 (바차트엔 의미 없음) const active = chart.tooltip && chart.tooltip._active; if (!active || !active.length) return; // 마우스가 차트 위에 없으면 아무것도 안 그림 const ctx = chart.ctx; const x = active[0].element.x; // 현재 활성 포인트의 x 좌표 const area = chart.chartArea; ctx.save(); ctx.beginPath(); ctx.moveTo(x, area.top); ctx.lineTo(x, area.bottom); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(100, 116, 139, 0.5)'; // 반투명 슬레이트 — 데이터 라인보다 튀지 않게 ctx.setLineDash([4, 4]); // 점선 — "이건 데이터가 아니라 가이드"임을 시각적으로 구분 ctx.stroke(); ctx.restore(); },};// 앱 초기화 시 한 번만: Chart.register(crosshairPlugin) — 등록 후엔 모든 차트 인스턴스에 자동 적용됨if (typeof Chart !== 'undefined' && Chart.register) Chart.register(crosshairPlugin);
이 플러그인이 작동하려면 반드시 함께 필요한 옵션
chart.tooltip._active가 채워지려면 차트 옵션에 interaction: { mode: 'index', intersect: false }가 있어야 한다(§9.3 professionalLineOptions() 참고) — mode: 'index'는 “마우스 x좌표와 가장 가까운 x축 인덱스의 모든 데이터셋”을 한 번에 active로 잡아주고, intersect: false는 정확히 포인트 위에 있지 않아도(차트 영역 어디든) 반응하게 한다. 이 옵션 없이는 크로스헤어가 포인트 정중앙에 마우스를 올렸을 때만 깜빡이며 사실상 못 쓴다.
재사용 시: 플러그인 자체는 그대로 복붙 가능(차트별 커스터마이징 불필요). 색상(strokeStyle)만 사이트 팔레트에 맞춰 조정하면 된다. 바차트에는 크로스헤어 개념이 안 맞으므로 chart.config.type !== 'line' 가드로 자동 스킵되게 해뒀다 — 같은 플러그인을 라인/바 공용으로 등록해도 안전하다.
9.3 라인차트 공통 옵션 팩토리 — professionalLineOptions()
차트마다 툴팁 스타일·범례·그리드·축 포맷을 매번 새로 쓰지 않고, 옵션을 만들어주는 함수 하나를 두고 차트마다 값 포맷터(yFmt/tipFmt)와 범례 표시 여부만 인자로 넘긴다:
function professionalLineOptions(yFmt, tipFmt, showLegend) { return { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, // §9.2 크로스헤어 필수 조건 plugins: { legend: { display: !!showLegend, position: 'bottom', usePointStyle: false, labels: { boxWidth: 12, boxHeight: 12, padding: 14, font: { size: 11, family: TOOLTIP_FONT_FAMILY }, // 평균선 같은 보조 계열은 범례에서 숨김 — 범례가 "진짜 데이터 계열"만 나열하게 filter: (item, data) => !(data.datasets[item.datasetIndex] && data.datasets[item.datasetIndex].isAvgLine), }, }, tooltip: { backgroundColor: '#0f172a', padding: 9, cornerRadius: 6, usePointStyle: false, titleFont: TOOLTIP_TITLE_FONT, bodyFont: TOOLTIP_BODY_FONT, filter: (item) => !(item.dataset && item.dataset.isAvgLine), // 연도 비교 차트(라벨이 "YYYY..." 형태)는 최신 연도가 툴팁 맨 위에 오도록 정렬 // (§9.11) — 연도 라벨이 아닌 차트(지역/브랜치 비교 등)는 원래 순서를 그대로 둔다. itemSort: (a, b) => { const yearOf = (label) => { const m = /^(\d{4})/.exec(label || ''); return m ? parseInt(m[1], 10) : null; }; const ya = yearOf(a.dataset && a.dataset.label), yb = yearOf(b.dataset && b.dataset.label); if (ya !== null && yb !== null) return yb - ya; return a.datasetIndex - b.datasetIndex; }, callbacks: { label: (ctx) => ctx.dataset.label + ': ' + tipFmt(ctx.raw), labelColor: (ctx) => ({ borderColor: ctx.dataset.borderColor, backgroundColor: ctx.dataset.backgroundColor, borderWidth: 0, borderRadius: 2 }), }, }, datalabels: { display: false }, // 기본은 꺼두고, 필요한 차트에서만 dataset 단위로 개별 활성화(§9.4) }, scales: { y: { ticks: { callback: yFmt, font: { size: 10 } }, grid: { color: '#f1f5f9' } }, // 아주 옅은 그리드 — 데이터보다 튀면 안 됨 x: { grid: { display: false }, ticks: { font: { size: 10 } } }, // x축 그리드는 아예 끔(중복 시각 노이즈) }, };}
설계 이유:
차트 제목에 이미 계열명이 드러나면 하단 범례는 정보 중복 — 예를 들어 캡션이 “NSW 월별 매출”이면 범례에 또 “NSW”가 뜨는 게 낭비. showLegend 인자로 차트별로 켜고 끈다(계열이 2개 이상 겹칠 때만 true).
툴팁은 다크(#0f172a) 배경 + 작은 폰트(10~11px) — Tailwind 카드들의 밝은 배경과 대비를 줘서 “이건 인터랙션 레이어”임을 시각적으로 분리.
평균선(isAvgLine) 같은 참고선은 범례·툴팁 모두에서 제외 — 참고용 보조 데이터가 실제 데이터와 같은 취급을 받으면 사용자가 혼동한다.
연도 비교 툴팁은 최신 연도가 맨 위 — itemSort로 계열 라벨 앞 4자리(/^(\d{4})/)를 연도로 인식해 내림차순 정렬. 연도 라벨이 아닌 계열(지역/브랜치 비교 등)은 datasetIndex 그대로 유지되므로 다른 호출부(chartDash 지역 비교)에는 영향 없음.
Key Insight
itemSort는 툴팁에 표시되는 항목의 “순서”만 바꾸는 콜백이다 — 데이터셋 배열 순서, 범례 순서, z-order, 색상 매핑(YEAR_PALETTE[i % ...])에는 전혀 영향을 주지 않는다. 그래서 professionalLineOptions()처럼 여러 차트가 공유하는 헬퍼에 연도 전용 정렬 로직을 넣어도 안전하다 — 라벨이 /^(\d{4})/에 매칭 안 되면(예: “NSW”, “VIC”) 조용히 원래 순서로 폴백한다. 앞으로 다연도 비교 툴팁을 새로 만들 때는 이 패턴(라벨 정규식 매칭 + 매칭 실패 시 datasetIndex 폴백)을 그대로 재사용할 것 — 데이터셋 자체를 역순으로 만들지 말 것(범례·색상까지 같이 뒤집혀 버그가 된다).
display: true로 라벨을 항상 강제로 켜면, 데이터 포인트가 많은(예: 일별 12개월) 라인차트에서 숫자가 서로 겹쳐 읽을 수 없는 죽만 된다. display: 'auto'는 Chart.js가 렌더 시점에 실제 겹침 여부를 계산해서 겹치는 라벨을 자동으로 숨긴다 — 데이터 밀도가 낮을 땐(연도별 비교처럼 포인트 12개 이하) 전부 보이고, 밀도가 높아지면 자동으로 솎아진다. 포인트 개수가 가변적인 대시보드(사용자가 기간 필터를 바꿀 수 있는 모든 차트)에서는 true 대신 항상 'auto'를 쓸 것.
언제 세로(기본) vs 가로(indexAxis: 'y')를 쓰나: 카테고리 라벨(모델명·거래처명 등)이 길거나 개수가 많아 x축에 다 못 들어갈 것 같으면 무조건 가로 막대(indexAxis: 'y')로 뒤집는다 — Top-N 랭킹류 차트는 거의 항상 가로가 정답. 반대로 시간축(월/연도)처럼 짧고 고정된 라벨이면 세로(기본값)를 유지한다.
2항 비교(지역 등)는 의미 고정 배색: NSW=#2563eb(파랑), VIC=#f97316(주황)를 대시보드 전체 모든 차트·KPI 아이콘에서 예외 없이 유지한다. 사용자가 차트마다 색-의미를 다시 학습하지 않아도 되게 하는 것이 목적 — 파란색을 보면 항상 NSW라고 즉시 인지할 수 있어야 한다.
N항 비교(연도별 등)는 순환 팔레트: YEAR_PALETTE = ['#2563eb','#f97316','#16a34a','#dc2626','#9333ea','#0891b2','#ca8a04'] — 오래된 연도부터 배열 순서대로 할당해 매번 같은 연도가 같은 색을 갖게 한다(연도 필터를 바꿔도 남아있는 연도는 색이 안 바뀜).
KPI 아이콘 배지 색은 차트 색과 별도 축: 카드 카테고리(수주=인디고, 출고=에메랄드, 매출=블루, 리베이트/기타=앰버)로 고정하며, 지역 배색(NSW/VIC)과는 다른 목적의 색 코딩이므로 절대 섞지 않는다(§10.3).
9.8 “잘려서 안 보이는” 데이터라벨 문제 — 막대 안쪽 전환 + 축약 표기 + auto 숨김의 3단 방어
실제로 발생한 버그 (2026-08-19 사용자 리포트)
막대차트 데이터라벨의 기본 배치(anchor:'end', align:'end' — 막대 끝 바로 바깥쪽)는 막대 값이 y축 최댓값에 가까울수록 그 “바깥쪽” 공간이 좁아진다. 공간이 부족하면 라벨이 차트 캔버스 밖으로 잘려 아예 안 보이게 된다 — “동월 비교” 그룹 막대차트에서 실제로 이 증상이 보고됨. 값 자체는 계산이 맞는데 화면에 숫자가 안 뜨니 사용자 입장에선 “차트가 고장났다”로 보인다.
해결책은 한 가지 트릭이 아니라 3단 방어를 같이 쓰는 것이다 — 각각 막는 실패 모드가 다르다:
여백 부족 → 막대 안쪽으로 전환(핵심 수정): align/color를 정적 값이 아니라 함수로 주면, chartjs-plugin-datalabels가 렌더 시점마다 그 함수를 호출해 막대별로 다르게 배치할 수 있다. 막대 끝과 차트 영역 경계 사이 여백을 실측해서, 부족하면 align:'start'(막대 안쪽, 원점 쪽)로 전환하고 색도 흰색으로 바꿔 채움색 위에서 읽히게 한다.
가로 막대(indexAxis:'y', Top-N 랭킹류)는 축이 90도 돌아가므로 판정 방향도 x 기준으로 바꿔야 한다 — horizontal 옵션 하나로 세로/가로 공용 헬퍼를 만든다.
라인차트 포인트 라벨은 smartLineDatalabels() — 픽셀 판정 + 값 판정 이중 안전장치: 처음엔 clamp:true만 추가해 “막대 안쪽 전환”과 같은 효과를 노렸으나, 실제로는 정점(최고값) 포인트의 라벨이 위/아래 어디에도 안 보이는 사례가 배포 후 발견됐다(2026-08-19) — clamp:true는 위치를 차트 영역 안으로 밀어넣을 뿐 align을 안 바꾸므로, 여전히 align:'top'로 축 상단 경계 바로 위에 겹쳐 그려지다가 display:'auto'의 충돌 감지에 걸려 통째로 숨겨진 것으로 추정된다. 해결은 막대와 동일하게 align 자체를 함수로 만들어 동적으로 위/아래를 바꾸는 것 — 단, 판정 기준을 두 겹으로 둔다:
function smartLineDatalabels(formatterFn, opts) { opts = opts || {}; const minHeadroom = opts.minHeadroom || 22; // (a) 픽셀 판정 - 렌더된 포인트가 차트 영역 상단에 너무 가까우면 const clippedByPixel = (ctx) => { const meta = ctx.chart.getDatasetMeta(ctx.datasetIndex); const el = meta && meta.data && meta.data[ctx.dataIndex]; if (!el) return false; return (el.y - ctx.chart.chartArea.top) < minHeadroom; }; // (b) 값 판정 - 이 데이터셋 안에서 최고값(정점)인 포인트는 축 여유폭과 무관하게 // 항상 위쪽 공간이 가장 부족하므로, 픽셀 계산의 레이아웃 타이밍 이슈와 별개로 // 항상 안전하게 판정된다 - 실제 버그를 잡은 건 이 (b) 쪽이었다. const isDatasetPeak = (ctx) => { const data = ctx.dataset.data; const val = data[ctx.dataIndex]; const nums = data.filter((v) => v !== null && v !== undefined && !isNaN(v)); return nums.length && val !== null && val !== undefined && !isNaN(val) && val >= Math.max(...nums); }; return { display: 'auto', anchor: 'end', clamp: true, align: (ctx) => (isDatasetPeak(ctx) || clippedByPixel(ctx) ? 'bottom' : 'top'), color: opts.color || DATALABEL_COLOR, font: opts.font || { size: 9, weight: '600' }, formatter: formatterFn, };}
교훈: 픽셀 기반 판정(“렌더된 좌표가 여백 안에 있는가”)은 직관적이지만 Chart.js 레이아웃 계산 타이밍이나 플러그인 내부 충돌 감지와 상호작용하면 간헐적으로 어긋날 수 있다. “이 포인트가 데이터셋의 최댓값인가”처럼 원본 데이터만으로 판정 가능한 조건을 이중 안전장치로 병행하면, 렌더링 파이프라인의 불확실성과 무관하게 항상 정확하다 — 가능하면 픽셀 판정보다 데이터 판정을 우선하는 것이 이번 사례의 결론.
숫자를 축약형으로(498k/1.2M) — 위 두 가지가 “잘림”을 막는다면, 이건 애초에 “겹침” 자체를 줄인다. 라벨 폭이 좁을수록 옆 라벨과 부딪힐 확률이 낮아진다. fmtMoneyCompact(기존)에 맞춰 건수용 fmtIntCompact를 대칭으로 만들어 차트 위 라벨에는 항상 이 축약형을 쓰고, 툴팁(마우스 오버)에는 그대로 fmtMoney/fmtInt 정확한 값을 쓴다 — “차트 위 상시 라벨은 축약, 호버 시에만 정확한 숫자”가 원칙.
function fmtIntCompact(n) { if (n === null || n === undefined || isNaN(n)) return '0'; const abs = Math.abs(n); if (abs >= 999500) return (n / 1000000).toFixed(2) + 'M'; // 999,999가 "1000k"로 반올림되는 경계 케이스 방지 if (abs >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; return Math.round(n).toLocaleString('en-AU');}
그래도 겹치면 그때만 숨긴다: 위 세 가지를 다 해도 라벨이 촘촘한 구간(짧은 기간에 값이 몰린 라인차트, 막대가 아주 얇은 그룹 막대 등)에서는 여전히 부딪힐 수 있다 — 이건 display: 'auto'(정적 true가 아니라)에 맡긴다. Chart.js/플러그인이 실제 렌더 시점에 다른 요소와 겹치는 라벨만 골라서 숨기므로, “일단 최대한 보여주고, 정말 안 되는 것만 숨긴다”는 원칙이 코드 한 줄(display:'auto')로 구현된다. display:false(항상 숨김)로 시작하지 말 것 — 처음부터 안 보이게 하면 “혹시 보여줄 수 있었는데 안 보여준” 라벨까지 다 놓친다.
재사용 체크리스트: 새 막대/라인차트를 추가할 때 (1) datalabels를 정적 객체 대신 smartBarDatalabels()(막대) 또는 smartLineDatalabels()(라인, 픽셀+값 이중 판정)로 만들 것, (2) 포매터는 항상 fmtMoneyCompact/fmtIntCompact 같은 축약형을 쓰고 원본 정밀값은 툴팁에만 남길 것, (3) display는 'auto'로 시작해서 정말 필요할 때만(예: 이미 범례로 계열이 다 보이는 등) false로 낮출 것.
9.9 트렌드 차트가 데이터 없는 기간을 건너뛰던 버그 — 빈 기간도 0으로 채우기
Contradiction (실제 발생한 버그, 2026-08-21 수정)
buildTrendSeries()/buildTrendSeriesByBranches()가 원래 “행 데이터에 실제로 등장한 기간 키만” 모아 Object.keys(buckets).sort()로 x축을 만들었다 — 값이 0인 달/분기가 아예 그 기간 자체를 건너뛰어 차트 축에서 사라지는 문제가 있었다(예: 어떤 딜러가 특정 달에 매출이 0이면, 그 달이 x축에 안 나타나고 인접한 두 달이 곧바로 붙어 보여 추이가 왜곡됨). David 리포트: “값이 없는 기간(월간 등)에는 값이 0이 표시되어 모든 월이 표시되도록 해줘.”
해결 패턴: 데이터에서 나온 키 대신, 선택된 from~to 구간 안의 모든 기간 키를 granularity별로 직접 생성(fullPeriodKeys(from, to, granularity))해 항상 그 목록으로 x축을 만들고, 값이 없는 키는 || 0으로 채운다.
function fullPeriodKeys(from, to, granularity) { // year: 연도 정수 순회 / quarter·month: 연+분기(또는 월) 캐리 순회 / week: mondayOf() 기준 +7일씩 / // day: +1일씩 — 각 granularity가 실제 존재하는 기간 수만큼만 순회하므로 day/week도 안전(수천 개 이하).}function buildTrendSeries(rows, dateField, metricFn, granularity, from, to) { const buckets = {}; for (const r of rows) { const key = bucketKey(r[dateField], granularity); if (!key) continue; buckets[key] = (buckets[key] || 0) + metricFn(r); } const keys = (from && to) ? fullPeriodKeys(from, to, granularity) : Object.keys(buckets).sort(); // from/to 생략 시 예전 방식(하위호환) return { keys, values: keys.map((k) => buckets[k] || 0) };}
재사용 체크리스트: 새 트렌드 차트를 추가할 때 buildTrendSeries/buildTrendSeriesByBranches 호출부에 반드시 computeRange(f)로 구한 from/to를 같이 넘길 것 — 안 넘기면 예전처럼 빈 기간이 조용히 사라진다. 이 fix와 함께 사이드바 “기간 단위” 필터에 분기(quarter) granularity도 추가했다(bucketKey에 iso.slice(0,4)+'-Q'+Math.ceil(month/3) 분기, computeRange에 f.fromQuarter/f.toQuarter 분기 — 연/분기/월/주/일 5종 모두 지원).
9.10 트리맵 “기타” 버킷 — 레이아웃 크기와 실제 값을 분리(sumKeys)
Chart.js chartjs-chart-treemap 플러그인으로 롱테일 분포(예: 거래처 325곳 중 320곳이 각자 5% 미만)를 시각화할 때, 작은 항목을 “기타”로 묶으면 그 묶음의 실제 합계값이 오히려 개별 큰 항목보다 커져서 트리맵을 통째로 잠식하는 문제가 생긴다(2026-08-21 실사용 피드백 — “기타”가 전체 면적의 56~73%를 차지). “기타”의 진짜 숫자를 라벨/툴팁에서는 그대로 보여주면서, 트리맵 레이아웃(면적) 계산에는 작은 캡값을 쓰고 싶다면 dataset.sumKeys로 값을 이중화한다:
// tree 항목: 레이아웃용 value(캡된 작은 값) + 실제값 realValue를 별도 필드로 둔다large.push({ name: otherLabel, value: Math.min(otherValue, smallestLarge * 0.5), realValue: otherValue });// dataset 설정: key가 레이아웃(면적)을 결정하고, sumKeys에 추가 필드를 명시하면 group() 단계에서// 그 필드도 함께 합산돼 최종 렌더 요소(ctx.raw.values.realValue / ctx.raw._data.realValue)로 살아남는다.{ tree, key: 'value', groups: ['name'], sumKeys: ['realValue'] }
라벨/툴팁 포맷터는 ctx.raw.values.realValue(없으면 ctx.raw._data.realValue, 그것도 없으면 ctx.raw.value)를 우선 읽어 항상 “캡 없는 진짜 숫자”를 표시한다. squarify 알고리즘이 값 내림차순으로 배치하므로, “기타”의 레이아웃값을 가장 작은 개별 항목보다 작게 캡해두면 자동으로 목록 맨 끝(오른쪽 아래 구석)에 작게 배치된다 — 별도 위치 지정 로직 없이 “작게 만들기”만으로 위치 문제도 함께 해결된다.
Key Insight
chartjs-chart-treemap의 group() 내부 구현은 key(기본 sizing 필드) 외의 임의 필드를 그룹 노드에 보존하지 않는다 — sumKeys에 명시한 필드만 예외적으로 합산 보존된다. 이걸 모르고 커스텀 필드(예: share)를 그냥 tree 항목에 얹으면, ctx.raw에서 undefined로 사라져 포맷터가 조용히 깨진다(2026-08-21 다른 세션에서 실제 겪은 별도 버그 — “박스가 1개만 그려지거나 아예 안 그려짐”의 원인이었다. Chart.js는 scriptable 콜백에서 예외가 나면 그 뒤 요소를 전부 안 그린다).
모든 KPI 카드는 class="kpi-card bg-white rounded-xl border border-slate-200 shadow-sm p-3.5"(Tailwind) + 위 hover 트랜지션을 공유한다. hover 시 translateY(-2px) + 그림자 강화만으로 “이 박스는 정적 텍스트가 아니라 살아있는 데이터”라는 느낌을 준다 — 클릭 가능하지 않은 순수 정보 카드에도 이 미세한 인터랙션을 넣어 화면 전체가 딱딱하지 않게 한다.
라벨 (text-xs, text-slate-400, uppercase) — 뭘 세고 있는지, 가장 눈에 덜 띄어야 함(값보다 작고 흐림).
값 (text-2xl font-bold text-slate-900) — 카드에서 시각적으로 가장 무거운 요소. 색은 항상 진한 슬레이트(중립) — 값 자체에 의미색(빨강/초록 등)을 넣지 않는 것이 원칙(§10.4의 예외 제외).
아이콘 배지 (우측 상단 고정, w-9 h-9, 카테고리별 파스텔 배경 + 진한 아이콘색) — 텍스트를 안 읽어도 카드 종류를 색+아이콘으로 즉시 구분하게 해주는 보조 단서. 절대 값보다 시각적으로 더 강조되면 안 된다(항상 파스텔 배경).
각주 (text-[11px] text-slate-500, 카드 맨 아래) — 이 숫자가 정확히 무슨 기준으로 집계됐는지(예: “Inv Date 기준 · Restocked 제외”). 카드마다 집계 규칙이 미묘하게 다를 때(§6) 이 각주가 없으면 사용자가 두 KPI를 잘못 비교하게 된다 — 집계 기준이 하나라도 특이하면 반드시 각주로 명시하는 것을 원칙으로 한다.
10.3 아이콘 배지 색상 = 카테고리 분류 체계
카테고리
배경
아이콘 색
예시
수주(주문)
bg-indigo-50
text-indigo-600
수주건수
출고(배송/매출인식)
bg-emerald-50
text-emerald-600
출고건수
매출/금액
bg-blue-50
text-blue-600
매출액
리베이트/기타 파생지표
bg-amber-50
text-amber-600
리베이트 차감 후 매출
새 KPI를 추가할 때 이 4개 카테고리 중 어디에 속하는지 먼저 판단하고 대응하는 배지 색을 그대로 쓴다 — 임의로 새 색을 고르면 사용자가 “이건 무슨 종류의 지표지?”를 매번 다시 판단해야 해서 학습된 색-의미 매핑이 깨진다. 정말 새로운 카테고리가 필요하면(4개로 안 덮이면) 팔레트에 새 색을 추가하되, 반드시 이 표에 항목을 추가해 문서화한다.
10.4 지역별 색상 카드 (사이드바 “오늘의 현황”) — 배지+라벨+값을 한 색으로 묶기
일반 KPI 카드(§10.2)와 달리 이 “지역 미니 카드”는 값 텍스트 색까지 지역색(text-blue-700/text-orange-600)으로 물들인다 — §10.2의 “값은 항상 중립색” 원칙의 유일한 의도적 예외다. 이유: 이 카드는 지역 비교가 목적(NSW vs VIC를 나란히 보여줌)이므로, 카드 전체(배경+배지+값)를 지역색으로 통일해야 “이게 NSW 숫자다”를 값만 보고도 즉시 알 수 있다 — 카테고리 분류가 목적인 일반 KPI(§10.3)와 비교 대상 강조가 목적인 카드는 색 적용 범위를 다르게 가져가는 것이 맞다.
"합계" 카드는 브랜치색이 아니라 중립색(slate)로 — 맨 왼쪽에 배치
“오늘 브랜치별 현황”(§0의 KPI 카드 그리드, NSW/VIC/QLD 3장)에 전체 합계 카드를 추가할 때(2026-08-19, David 요청), 이 카드는 어느 한 브랜치도 아니므로 §10.4의 지역색 원칙을 안 따르고 중립 slate(bg-slate-100 text-slate-700, 아이콘 fa-layer-group)를 쓴다 — 개별 브랜치 카드들과 색으로 구분돼야 “이건 합산값이다”가 즉시 read된다. 배치는 항상 맨 왼쪽(그리드의 첫 번째 자식) — 합계가 먼저 보이고 그 아래 브랜치별 분해가 뒤따르는 순서가 자연스러운 정보 위계다. 계산은 새 로직 없이 이미 계산된 브랜치별 stats 객체를 그대로 reduce()로 합산 — 별도 집계 경로를 만들지 않는다(집계 규칙이 두 곳에서 갈라질 위험 제거).
실제 <input type="checkbox">는 sr-only(시각적으로 숨김, 스크린리더엔 노출)로 두고, 옆의 <div>를 Tailwind peer-checked:* 유사선택자로 스타일링해 토글 UI처럼 보이게 만드는 표준 패턴이다. 재사용 시: 이 블록은 값만 바꾸지 말고 통째로 복붙 후 id/라벨 텍스트/아이콘만 교체 — peer/sr-only/after:* 클래스 하나라도 빠지면 토글이 안 움직이거나 시각적으로 깨진다.
새로 추가한 공통 docnav와 페이지 자체의 stickyTopBar가 둘 다 top:0이라 서로 겹침
docnav 실제 높이를 JS로 측정해 CSS 변수로 stickyTopBar의 top에 반영
(사전 개발 세션) YrCompare 탭 진입 시 차트 미표시 + 필터 미연동, 3라운드 연속 “수정했다”고 오판
renderCoreKpis('yr')가 YrCompare 패널에 존재하지 않는 DOM 엘리먼트에 .textContent 대입 → 실제 브라우저 TypeError로 함수 중단, 다음 줄 렌더 호출 무산. 테스트가 못 잡은 이유는 DOM 스텁이 모든 id에 가짜 엘리먼트를 만들어줬기 때문(브라우저의 null 반환을 흉내 내지 못함)
compareMode가 f.region === 'NSW_VIC_QLD'(사이드바 지역 필터가 “전체”일 때)로만 하드코딩돼 있었는데, 그 필터의 기본값은 NSW_VIC — 기본 상태에서 토글을 눌러도 조건이 안 맞아 항상 무반응
”동월 비교” 차트가 이미 쓰던 MULTI_BRANCH_SETS(지역→브랜치 배열 매핑)를 모듈 공유 상수로 승격해 재사용, compareMode를 !!MULTI_BRANCH_SETS[f.region] && f.regionMode==='compare'로 재정의 — 지역 필터가 아우르는 브랜치 개수만큼 라인 동적 생성. 버튼 라벨도 “브랜치 비교”로 일반화(2026-08-19)
“동월 비교” 콤보차트에서 정점(최고값) 라인 포인트의 값 레이블이 위/아래 어디에도 안 보임
clamp:true만으로는 align이 여전히 'top' 고정이라, 축 상단 경계 바로 위에 겹쳐 그려지다 display:'auto'의 충돌 감지에 걸려 숨겨짐(픽셀 판정만으로는 간헐적으로 놓침)
§9.8 참고 — smartLineDatalabels()에 “이 데이터셋의 최댓값 포인트인가”라는 값 기반 이중 판정을 추가해 무조건 align:'bottom'으로 전환(2026-08-19)
(2026-08-20 지역 필터 리팩터 이후) “동월 비교” 차트에서 전체 브랜치 선택 시 막대 3개가 모두 같은 값(전체 합계)
지역 필터를 5종 고정 문자열(NSW_VIC_QLD 등)에서 체크박스 배열(['NSW','VIC','QLD'])로 바꾸면서 regionMatchesBranch()가 “전체 선택”을 region.length===3으로 판별했는데, 개별 브랜치 코드(“NSW” 등)도 우연히 3글자라 단일 브랜치 문자열을 넘겨도 항상 “전체”로 오인
Array.isArray(region)으로 배열/문자열 입력을 명확히 분기 — 배열이면 길이 기반 “전체” 판정, 문자열이면 정확히 일치하는 값만 비교
”실적 종합”과 “오늘의 현황”의 같은 달 출고건수가 서로 다름(예: 302 vs 300)
“실적 종합” 트렌드 차트가 countMetric(모든 행을 1로 카운트)을 써서 Restocked 행까지 포함 — 사이트 전체 규칙(dispatchCount(), Restocked 제외)과 불일치. 두 화면 다 “출고건수”라는 같은 이름을 쓰면서 실제 계산 함수가 달랐던 게 근본 원인
해당 metricFn을 (r) => isRestockedRow(r) ? 0 : 1로 교체해 dispatchCount()와 동일 규칙으로 통일. 재사용 교훈: 같은 라벨(출고건수/수주건수 등)을 여러 차트·KPI 카드에서 반복 계산할 땐 반드시 공유 헬퍼 함수(dispatchCount() 등)를 통해서만 계산 — 로컬에서 countMetric처럼 범용 카운터를 재사용하면 비즈니스 규칙(Restocked 제외 등)이 조용히 빠지기 쉽다
Open Question
turboairbrain.uk에서 TAB 아이콘을 가져와 대시보드 헤더에 쓰려는 시도가 401 Unauthorized로 실패해, 현재도 “TAB” 텍스트 배지로 대체돼 있다(2026-08-17 세션에서 발견, 미해결로 이월). Basic Auth 뒤의 정적 자산을 별도 페이지에서 직접 fetch하려 한 게 원인으로 추정되지만 확정되지 않았다 — 다음에 다룰 때는 아이콘을 public/의 별도 미인증 경로로 옮기거나, base64 data URI로 인라인하는 방법을 우선 검토.