diff --git a/.gitignore b/.gitignore
index 19a3605..13a6444 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ data/meeting-ai-checklist.json
# PPT 썸네일 이벤트 로그(PG 미사용 시 폴백·마이그레이션 백업)
data/thumbnail-events.json
data/thumbnail-events.json.migrated.bak
+data/mgmt-perf-last-state.json
diff --git a/README.md b/README.md
index 68565eb..97099ce 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@
- 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃)
- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동
- **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결
+- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 기존 HTML 템플릿(Chart.js, 매출·수주·예상실적 탭)을 iframe(`/dashboard/business-performance/embed`)으로 표시. 엑셀에서 수치 집계를 치환하려면 `npm install`로 `xlsx` 패키지를 설치한 뒤 서버를 재시작하세요.
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
- 검색/필터/페이지네이션
- 검색어(`q`) 기반 제목/설명/태그 필터
@@ -81,7 +82,7 @@ ai_platform/
├─ .env # 환경 변수 (실제 값, .gitignore 대상)
├─ .env.example # 환경 변수 예시 템플릿
├─ db/
-│ └─ schema.sql # PostgreSQL lectures 테이블 스키마 (서버 기동 시 자동 적용)
+│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과 업로드 등, 기동 시 자동 적용)
├─ scripts/
│ └─ apply-schema.js # 수동 스키마 적용 스크립트 (npm run db:schema)
├─ public/
@@ -95,7 +96,9 @@ ai_platform/
│ ├─ chat.ejs # 채팅
│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 프롬프트 카드·검색)
│ ├─ dashboard.ejs # 대시보드 목록(카드·검색)
-│ ├─ dashboard-business-performance.ejs # 경영성과 대시보드(진입·안내)
+│ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + iframe 조회)
+│ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(iframe용)
+│ ├─ partials/mgmt_perf_dashboard_container.ejs
│ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사)
│ ├─ ai-cases.ejs # AI 성공 사례 목록(카드)
│ ├─ ai-case-detail.ejs # AI 성공 사례 상세(마크다운)
diff --git a/data/mgmt-perf-default-payload.json b/data/mgmt-perf-default-payload.json
new file mode 100644
index 0000000..b730dea
--- /dev/null
+++ b/data/mgmt-perf-default-payload.json
@@ -0,0 +1,1160 @@
+{
+ "UM": {
+ "all": {
+ "nm": "전체",
+ "c": "#cc0000",
+ "q1": 5290192653,
+ "tgt": 4871420876,
+ "ann": 24623615592,
+ "ub": 2236188182,
+ "mp": [
+ 312000000,
+ 251300000,
+ 583500000
+ ],
+ "mc": [
+ 196001807,
+ 208677651,
+ 369427692
+ ],
+ "mg": [
+ 0,
+ 0,
+ 0
+ ],
+ "dom": 0,
+ "ovs": 0
+ },
+ "fscan": {
+ "nm": "FSCAN 국내영업본부",
+ "c": "#cc0000",
+ "q1": 1920907150,
+ "tgt": 1114000000,
+ "ann": 6809000000,
+ "ub": 1280788000,
+ "mp": [
+ 1238900000,
+ 628300000,
+ 1101700000
+ ],
+ "mc": [
+ 196001807,
+ 208677651,
+ 369427692
+ ],
+ "mg": [
+ 0,
+ 0,
+ 0
+ ],
+ "dom": 1634743000,
+ "ovs": 286164150
+ },
+ "fscanovs": {
+ "nm": "FSCAN 해외영업본부",
+ "c": "#1565C0",
+ "q1": 1284315509,
+ "tgt": 1299144198,
+ "ann": 4505397822,
+ "ub": 0,
+ "mp": [
+ 226277748,
+ 0,
+ 889670277
+ ],
+ "mc": [
+ 0,
+ 0,
+ 78398496
+ ],
+ "mg": [
+ 23950000,
+ 45518988,
+ 29700000
+ ],
+ "dom": 25750000,
+ "ovs": 1258565509
+ },
+ "xscan": {
+ "nm": "XSCAN 영업본부",
+ "c": "#2E7D32",
+ "q1": 739012477,
+ "tgt": 669663100,
+ "ann": 6189663100,
+ "ub": 427300000,
+ "mp": [
+ 450365100,
+ 109222110,
+ 515000000
+ ],
+ "mc": [
+ 63092810,
+ 136094900,
+ 140237557
+ ],
+ "mg": [
+ 0,
+ 0,
+ 0
+ ],
+ "dom": 471942500,
+ "ovs": 267069977
+ },
+ "xscanovs": {
+ "nm": "XSCAN 해외영업본부",
+ "c": "#0D47A1",
+ "q1": 183055482,
+ "tgt": 567893578,
+ "ann": 1969439954,
+ "ub": 0,
+ "mp": [
+ 0,
+ 76692060,
+ 106363422
+ ],
+ "mc": [
+ 0,
+ 0,
+ 0
+ ],
+ "mg": [
+ 0,
+ 0,
+ 0
+ ],
+ "dom": 0,
+ "ovs": 183055482
+ },
+ "battery": {
+ "nm": "배터리 사업본부",
+ "c": "#F57C00",
+ "q1": 1107902035,
+ "tgt": 1145800000,
+ "ann": 5150114716,
+ "ub": 435100000,
+ "mp": [
+ 0,
+ 438000000,
+ 435000000
+ ],
+ "mc": [
+ 58620650,
+ 56272700,
+ 163008685
+ ],
+ "mg": [
+ 0,
+ 0,
+ 0
+ ],
+ "dom": 1016019475,
+ "ovs": 91882560
+ },
+ "newbiz": {
+ "nm": "신사업본부",
+ "c": "#7B1FA2",
+ "q1": 55000000,
+ "tgt": 0,
+ "ann": 0,
+ "ub": 93000000,
+ "mp": [
+ 0,
+ 0,
+ 210000000
+ ],
+ "mc": [
+ 0,
+ 0,
+ 0
+ ],
+ "mg": [
+ 0,
+ 0,
+ 0
+ ],
+ "dom": 55000000,
+ "ovs": 0
+ }
+ },
+ "ORDER_CAT": {
+ "fscan": {
+ "op": [
+ 644000000,
+ 161000000,
+ 405200000
+ ],
+ "og": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fscanovs": {
+ "op": [
+ 44127400,
+ 0,
+ 713117356
+ ],
+ "og": [
+ 0,
+ 20500000,
+ 18533392
+ ]
+ },
+ "xscan": {
+ "op": [
+ 192722750,
+ 278000000,
+ 569280250
+ ],
+ "og": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "xscanovs": {
+ "op": [
+ 166049000,
+ 77236900,
+ 0
+ ],
+ "og": [
+ 30132900,
+ 0,
+ 0
+ ]
+ },
+ "battery": {
+ "op": [
+ 0,
+ 5422644,
+ 0
+ ],
+ "og": [
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "newbiz": {
+ "op": [
+ 0,
+ 0,
+ 210000000
+ ],
+ "og": [
+ 0,
+ 0,
+ 0
+ ]
+ }
+ },
+ "CUSTS": {
+ "fscan": [
+ {
+ "nm": "주식회사 네추럴웨이",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 352000000,
+ "ub": 350400000
+ },
+ {
+ "nm": "XAVIS TECH (CS)",
+ "gbn": "해외",
+ "acc": "CS매출",
+ "amt": 211242712,
+ "ub": 0
+ },
+ {
+ "nm": "상아생명과학 주식회사",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 113000000,
+ "ub": 113000000
+ },
+ {
+ "nm": "바틱그룹 주식회사",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 85000000,
+ "ub": 85000000
+ },
+ {
+ "nm": "㈜삼아벤처",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 65000000,
+ "ub": 78000000
+ },
+ {
+ "nm": "삼양스퀘어밀㈜",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 60000000,
+ "ub": 30000000
+ },
+ {
+ "nm": "㈜명성",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 46000000,
+ "ub": 46000000
+ },
+ {
+ "nm": "주식회사다정",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 44500000,
+ "ub": 0
+ },
+ {
+ "nm": "㈜빙그레",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 43500000,
+ "ub": 33950000
+ },
+ {
+ "nm": "삼우테크",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 43300000,
+ "ub": 0
+ }
+ ],
+ "fscanovs": [
+ {
+ "nm": "SCALE (인도)",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 408754094,
+ "ub": 0
+ },
+ {
+ "nm": "UNIPLAST (인도)",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 391004156,
+ "ub": 0
+ },
+ {
+ "nm": "XAVIS TECH",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 164307900,
+ "ub": 0
+ },
+ {
+ "nm": "NICHROME",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 145598937,
+ "ub": 0
+ },
+ {
+ "nm": "XAVIS TECH (CS)",
+ "gbn": "해외",
+ "acc": "CS매출",
+ "amt": 78398496,
+ "ub": 0
+ },
+ {
+ "nm": "UNIPLAST (상품)",
+ "gbn": "해외",
+ "acc": "상품매출",
+ "amt": 45518988,
+ "ub": 0
+ },
+ {
+ "nm": "복을만드는사람들",
+ "gbn": "국내",
+ "acc": "상품매출",
+ "amt": 14750000,
+ "ub": 0
+ },
+ {
+ "nm": "주식회사 한웅메디칼",
+ "gbn": "국내",
+ "acc": "상품매출",
+ "amt": 11000000,
+ "ub": 0
+ }
+ ],
+ "xscanovs": [
+ {
+ "nm": "LG INNOTEK",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 106363422,
+ "ub": 0
+ },
+ {
+ "nm": "TECHNO (베트남)",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 76692060,
+ "ub": 0
+ }
+ ],
+ "xscan": [
+ {
+ "nm": "에이에스이천안 (ASE Cheonan)",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 237000000,
+ "ub": 189600000
+ },
+ {
+ "nm": "BH SEMICON VINA",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 109222110,
+ "ub": 0
+ },
+ {
+ "nm": "TECHNO (CS)",
+ "gbn": "해외",
+ "acc": "CS매출",
+ "amt": 70945425,
+ "ub": 0
+ },
+ {
+ "nm": "엘지이노텍㈜",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 57870000,
+ "ub": 0
+ },
+ {
+ "nm": "BEIJING XAVIS",
+ "gbn": "해외",
+ "acc": "제품매출",
+ "amt": 53365100,
+ "ub": 0
+ },
+ {
+ "nm": "한국피아이엠㈜",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 50000000,
+ "ub": 0
+ },
+ {
+ "nm": "㈜바텍이엠엑스",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 44350000,
+ "ub": 0
+ },
+ {
+ "nm": "인탑스주식회사",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 23000000,
+ "ub": 0
+ },
+ {
+ "nm": "㈜유니온",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 22000000,
+ "ub": 0
+ },
+ {
+ "nm": "PIM VINA",
+ "gbn": "해외",
+ "acc": "CS매출",
+ "amt": 17952000,
+ "ub": 0
+ }
+ ],
+ "battery": [
+ {
+ "nm": "㈜베스텍",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 873000000,
+ "ub": 392100000
+ },
+ {
+ "nm": "NDR INTERNATIONAL COMMERCIAL",
+ "gbn": "해외",
+ "acc": "CS매출",
+ "amt": 91882560,
+ "ub": 0
+ },
+ {
+ "nm": "(주)엔트워크",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 43000000,
+ "ub": 0
+ },
+ {
+ "nm": "㈜엔트워크",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 43000000,
+ "ub": 43000000
+ },
+ {
+ "nm": "에스케이온 주식회사",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 32545575,
+ "ub": 0
+ },
+ {
+ "nm": "파테크",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 23000000,
+ "ub": 0
+ },
+ {
+ "nm": "㈜제이스텍",
+ "gbn": "국내",
+ "acc": "CS매출",
+ "amt": 1200000,
+ "ub": 0
+ }
+ ],
+ "newbiz": [
+ {
+ "nm": "주식회사 성진디에스피",
+ "gbn": "국내",
+ "acc": "제품매출",
+ "amt": 55000000,
+ "ub": 0
+ }
+ ]
+ },
+ "RISK_ROWS": [
+ {
+ "월": 3,
+ "본": "배터리",
+ "고": "㈜베스텍",
+ "품": "IBC221(B)",
+ "amt": 304500000
+ },
+ {
+ "월": 1,
+ "본": "XSCAN",
+ "고": "에이에스이천안(ASE)",
+ "품": "XSCAN-9800T",
+ "amt": 189600000
+ },
+ {
+ "월": 1,
+ "본": "FSCAN국내",
+ "고": "상아생명과학",
+ "품": "FSCAN-2500PHE(T)",
+ "amt": 113000000
+ },
+ {
+ "월": 3,
+ "본": "신사업",
+ "고": "㈜에스엠티비전",
+ "품": "XSCAN-1001BNST",
+ "amt": 93000000
+ },
+ {
+ "월": 2,
+ "본": "배터리",
+ "고": "㈜베스텍",
+ "품": "IBC221(B)",
+ "amt": 87600000
+ },
+ {
+ "월": 3,
+ "본": "FSCAN국내",
+ "고": "주식회사 하림",
+ "품": "FSCAN-4350GAC",
+ "amt": 78400000
+ },
+ {
+ "월": 3,
+ "본": "XSCAN",
+ "고": "에이티스마트(AT Smart)",
+ "품": "FSCAN-4400",
+ "amt": 77400000
+ },
+ {
+ "월": 1,
+ "본": "FSCAN국내",
+ "고": "바틱그룹",
+ "품": "FSCAN-6500DAC",
+ "amt": 76500000
+ }
+ ],
+ "ORDER_DATA": {
+ "monthly": [
+ {
+ "month": "1월",
+ "opp": 0,
+ "opp60": 0,
+ "actual": 10.77,
+ "plan": 42.47,
+ "bep": 31.12
+ },
+ {
+ "month": "2월",
+ "opp": 6.51,
+ "opp60": 6.23,
+ "actual": 5.26,
+ "plan": 24.26,
+ "bep": 31.12
+ },
+ {
+ "month": "3월",
+ "opp": 62.59,
+ "opp60": 57.48,
+ "actual": 13.03,
+ "plan": 135.65,
+ "bep": 31.12
+ },
+ {
+ "month": "4월",
+ "opp": 72.3,
+ "opp60": 31.98,
+ "actual": 0,
+ "plan": 29.87,
+ "bep": 31.12
+ },
+ {
+ "month": "5월",
+ "opp": 55.52,
+ "opp60": 18.91,
+ "actual": 0,
+ "plan": 28.47,
+ "bep": 31.12
+ },
+ {
+ "month": "6월",
+ "opp": 113.11,
+ "opp60": 37.44,
+ "actual": 0,
+ "plan": 35.29,
+ "bep": 31.12
+ }
+ ],
+ "divisions": {
+ "fscan": {
+ "nm": "FSCAN국내",
+ "actual": [
+ 6.44,
+ 1.61,
+ 2.96,
+ 0,
+ 0,
+ 0
+ ],
+ "plan": [
+ 3.68,
+ 4.21,
+ 5.51,
+ 5.57,
+ 5.25,
+ 7.98
+ ]
+ },
+ "xscan": {
+ "nm": "XSCAN국내",
+ "actual": [
+ 1.93,
+ 2.78,
+ 3.58,
+ 0,
+ 0,
+ 0
+ ],
+ "plan": [
+ 4.2,
+ 5.05,
+ 5.7,
+ 6.05,
+ 4.5,
+ 5.5
+ ]
+ },
+ "ovsFscan": {
+ "nm": "해외(FSCAN)",
+ "actual": [
+ 0.44,
+ 0.1,
+ 4.94,
+ 0,
+ 0,
+ 0
+ ],
+ "plan": [
+ 3.35,
+ 4.7,
+ 10.48,
+ 6.15,
+ 3.28,
+ 5.8
+ ]
+ },
+ "ovsXscan": {
+ "nm": "해외(XSCAN)",
+ "actual": [
+ 1.96,
+ 0.77,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "plan": [
+ 2.64,
+ 0.7,
+ 4.76,
+ 4,
+ 7.44,
+ 5.6
+ ]
+ },
+ "battery": {
+ "nm": "배터리",
+ "actual": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "plan": [
+ 28.6,
+ 9.6,
+ 62.8,
+ 0,
+ 0,
+ 0
+ ],
+ "opp": [
+ 0,
+ 0,
+ 34.68,
+ 36.2,
+ 36.1,
+ 85.2
+ ],
+ "opp60": [
+ 0,
+ 0,
+ 29.78,
+ 5.74,
+ 9.94,
+ 25.2
+ ]
+ },
+ "newbiz": {
+ "nm": "신사업",
+ "actual": [
+ 0,
+ 0,
+ 1.55,
+ 0,
+ 0,
+ 0
+ ],
+ "plan": [
+ 0,
+ 0,
+ 46.4,
+ 8.1,
+ 8,
+ 10.4
+ ]
+ }
+ }
+ },
+ "MODELS": {
+ "sales": {
+ "fscan": [
+ {
+ "model": "FSCAN-2500PHE(T)",
+ "amt": 930000000,
+ "cnt": 8,
+ "m": [
+ 465000000,
+ 233000000,
+ 232000000
+ ]
+ },
+ {
+ "model": "FSCAN-4280D",
+ "amt": 502700000,
+ "cnt": 13,
+ "m": [
+ 121000000,
+ 108000000,
+ 273700000
+ ]
+ },
+ {
+ "model": "FSCAN-2500PHE",
+ "amt": 190000000,
+ "cnt": 3,
+ "m": [
+ 0,
+ 65000000,
+ 125000000
+ ]
+ },
+ {
+ "model": "FSCAN-6500",
+ "amt": 170000000,
+ "cnt": 2,
+ "m": [
+ 85000000,
+ 0,
+ 85000000
+ ]
+ },
+ {
+ "model": "FSCAN-4280",
+ "amt": 168400000,
+ "cnt": 4,
+ "m": [
+ 126400000,
+ 42000000,
+ 0
+ ]
+ }
+ ],
+ "fscanovs": [
+ {
+ "model": "FSCAN-6350G(AC)",
+ "amt": 319085233,
+ "cnt": 2,
+ "m": [
+ 0,
+ 0,
+ 319085233
+ ]
+ },
+ {
+ "model": "FSCAN-6350G2",
+ "amt": 200245763,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 200245763
+ ]
+ },
+ {
+ "model": "FSCAN-2500PH",
+ "amt": 119296000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 119296000
+ ]
+ },
+ {
+ "model": "FSCAN-6280D(AC)",
+ "amt": 102157170,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 102157170
+ ]
+ },
+ {
+ "model": "FSCAN-6500D(H,AC)",
+ "amt": 79841900,
+ "cnt": 1,
+ "m": [
+ 79841900,
+ 0,
+ 0
+ ]
+ }
+ ],
+ "xscan": [
+ {
+ "model": "IBC221(B)",
+ "amt": 873000000,
+ "cnt": 2,
+ "m": [
+ 0,
+ 438000000,
+ 435000000
+ ]
+ },
+ {
+ "model": "XSCAN-9800T",
+ "amt": 474000000,
+ "cnt": 2,
+ "m": [
+ 237000000,
+ 0,
+ 237000000
+ ]
+ },
+ {
+ "model": "XSCAN-A130H",
+ "amt": 355222110,
+ "cnt": 4,
+ "m": [
+ 160000000,
+ 109222110,
+ 86000000
+ ]
+ },
+ {
+ "model": "XSCAN-1001BNST",
+ "amt": 155000000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 155000000
+ ]
+ },
+ {
+ "model": "FSCAN-4400",
+ "amt": 129000000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 129000000
+ ]
+ }
+ ],
+ "xscanovs": [
+ {
+ "model": "XSCAN-A100R",
+ "amt": 183055482,
+ "cnt": 2,
+ "m": [
+ 0,
+ 76692060,
+ 106363422
+ ]
+ }
+ ]
+ },
+ "orders": {
+ "fscan": [
+ {
+ "model": "FSCAN-4280D",
+ "amt": 269700000,
+ "cnt": 7,
+ "m": [
+ 71000000,
+ 119000000,
+ 79700000
+ ]
+ },
+ {
+ "model": "FSCAN-2500PHE(T)",
+ "amt": 232000000,
+ "cnt": 2,
+ "m": [
+ 232000000,
+ 0,
+ 0
+ ]
+ },
+ {
+ "model": "FSCAN-4400D",
+ "amt": 129000000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 129000000,
+ 0
+ ]
+ },
+ {
+ "model": "FSCAN-2500PHE",
+ "amt": 125000000,
+ "cnt": 2,
+ "m": [
+ 65000000,
+ 0,
+ 60000000
+ ]
+ },
+ {
+ "model": "FSCAN-4500DH",
+ "amt": 114000000,
+ "cnt": 2,
+ "m": [
+ 114000000,
+ 0,
+ 0
+ ]
+ }
+ ],
+ "fscanovs": [
+ {
+ "model": "FSCAN-4350G(AC)2",
+ "amt": 238147293,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 238147293
+ ]
+ },
+ {
+ "model": "FSCAN-4350G2",
+ "amt": 136092075,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 136092075
+ ]
+ },
+ {
+ "model": "FSCAN-4350G(AC)",
+ "amt": 125664000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 125664000
+ ]
+ },
+ {
+ "model": "FSCAN-9500D",
+ "amt": 114211185,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 114211185
+ ]
+ },
+ {
+ "model": "FSCAN-4280D(H,AC)",
+ "amt": 63053952,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 63053952
+ ]
+ }
+ ],
+ "xscan": [
+ {
+ "model": "XSCAN-9800T(2.5D)",
+ "amt": 448625100,
+ "cnt": 2,
+ "m": [
+ 0,
+ 0,
+ 448625100
+ ]
+ },
+ {
+ "model": "XSCAN-A130H",
+ "amt": 399377900,
+ "cnt": 4,
+ "m": [
+ 192722750,
+ 86000000,
+ 120655150
+ ]
+ },
+ {
+ "model": "XSCAN-1001BNST",
+ "amt": 155000000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 155000000
+ ]
+ },
+ {
+ "model": "XSCAN-A150L",
+ "amt": 63000000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 63000000,
+ 0
+ ]
+ },
+ {
+ "model": "XSCAN-1001BWSJ",
+ "amt": 55000000,
+ "cnt": 1,
+ "m": [
+ 0,
+ 0,
+ 55000000
+ ]
+ }
+ ],
+ "xscanovs": [
+ {
+ "model": "XSCAN-A100R",
+ "amt": 148981900,
+ "cnt": 2,
+ "m": [
+ 71745000,
+ 77236900,
+ 0
+ ]
+ },
+ {
+ "model": "XSCAN-A130R",
+ "amt": 94304000,
+ "cnt": 1,
+ "m": [
+ 94304000,
+ 0,
+ 0
+ ]
+ }
+ ]
+ }
+ },
+ "FORECAST": {
+ "plan": [
+ 42.47,
+ 24.26,
+ 135.65,
+ 29.87,
+ 28.47,
+ 35.29,
+ 27.88,
+ 27.46,
+ 61.39,
+ 44.04,
+ 17.23,
+ 25.16
+ ],
+ "bep": 31.12,
+ "actual": [
+ 10.77,
+ 5.26,
+ 13.03,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "annualPlanTotal": 499.17,
+ "annualBepTotal": 373.4
+ }
+}
\ No newline at end of file
diff --git a/db/schema.sql b/db/schema.sql
index 4a7b23e..61108da 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -270,3 +270,28 @@ CREATE TABLE IF NOT EXISTS lecture_thumbnail_events (
CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_occurred ON lecture_thumbnail_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_type ON lecture_thumbnail_events (event_type);
CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_lecture ON lecture_thumbnail_events (lecture_id);
+
+-- 경영성과 대시보드: 엑셀 업로드·차트용 JSON 스냅샷
+CREATE TABLE IF NOT EXISTS mgmt_perf_uploads (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_email VARCHAR(320),
+ original_filename TEXT NOT NULL,
+ fiscal_year INT NOT NULL,
+ quarter INT NOT NULL CHECK (quarter >= 1 AND quarter <= 4),
+ file_path TEXT NOT NULL,
+ file_size BIGINT,
+ parse_status VARCHAR(32) NOT NULL DEFAULT 'ok',
+ parse_error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_mgmt_perf_uploads_created ON mgmt_perf_uploads (created_at DESC);
+
+CREATE TABLE IF NOT EXISTS mgmt_perf_snapshots (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ upload_id UUID NOT NULL UNIQUE REFERENCES mgmt_perf_uploads (id) ON DELETE CASCADE,
+ payload JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_mgmt_perf_snapshots_upload ON mgmt_perf_snapshots (upload_id);
diff --git a/lib/mgmt-perf.js b/lib/mgmt-perf.js
new file mode 100644
index 0000000..10bd269
--- /dev/null
+++ b/lib/mgmt-perf.js
@@ -0,0 +1,184 @@
+/**
+ * 경영성과 대시보드: 엑셀 업로드 파싱·스냅샷 저장·최신 페이로드 조회
+ */
+const fs = require("fs");
+const path = require("path");
+
+function loadXlsx() {
+ try {
+ return require("xlsx");
+ } catch {
+ return null;
+ }
+}
+
+const ROOT = path.join(__dirname, "..");
+const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "data", "mgmt-perf-default-payload.json");
+const FILE_STATE_PATH = path.join(ROOT, "data", "mgmt-perf-last-state.json");
+
+function loadDefaultPayload() {
+ const raw = fs.readFileSync(DEFAULT_PAYLOAD_PATH, "utf8");
+ return JSON.parse(raw);
+}
+
+/**
+ * 매출일보 시트를 읽어 기본 페이로드에 메타를 덧붙입니다. (집계 치환은 추후 확장)
+ * @param {Buffer} buffer
+ * @param {object} defaultPayload
+ */
+function buildPayloadFromWorkbook(buffer, defaultPayload) {
+ const XLSX = loadXlsx();
+ if (!XLSX) {
+ const payload = JSON.parse(JSON.stringify(defaultPayload));
+ payload._uploadMeta = {
+ sheets: [],
+ primarySheet: null,
+ rowCount: 0,
+ importedAt: new Date().toISOString(),
+ note: "npm 패키지 `xlsx`가 없어 엑셀을 파싱하지 못했습니다. 프로젝트 루트에서 `npm install` 후 서버를 재시작하세요.",
+ };
+ return payload;
+ }
+ const wb = XLSX.read(buffer, { type: "buffer", cellDates: true });
+ const names = wb.SheetNames || [];
+ const sheetName = names.includes("매출일보") ? "매출일보" : names[0];
+ const ws = wb.Sheets[sheetName];
+ const matrix = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" });
+ const nonEmptyRows = matrix.filter((r) => Array.isArray(r) && r.some((c) => c !== "" && c != null));
+ const payload = JSON.parse(JSON.stringify(defaultPayload));
+ payload._uploadMeta = {
+ sheets: names,
+ primarySheet: sheetName,
+ rowCount: nonEmptyRows.length,
+ importedAt: new Date().toISOString(),
+ note:
+ "매출일보 행 수·시트명만 반영했습니다. 차트 수치를 엑셀 집계로 치환하려면 별도 매핑 로직이 필요합니다.",
+ };
+ return payload;
+}
+
+/**
+ * @param {import("pg").Pool | null} pgPool
+ * @param {{ userEmail: string | null, originalFilename: string, filePath: string, fiscalYear: number, quarter: number, payload: object }} row
+ */
+async function saveUploadAndSnapshot(pgPool, row) {
+ const { userEmail, originalFilename, filePath, fiscalYear, quarter, payload } = row;
+ const stat = fs.statSync(filePath);
+ if (!pgPool) {
+ fs.writeFileSync(
+ FILE_STATE_PATH,
+ JSON.stringify(
+ {
+ payload,
+ meta: {
+ originalFilename,
+ fiscalYear,
+ quarter,
+ savedAt: new Date().toISOString(),
+ fileSize: stat.size,
+ },
+ },
+ null,
+ 2
+ ),
+ "utf8"
+ );
+ return { id: null, fileBacked: true };
+ }
+ const ins = await pgPool.query(
+ `INSERT INTO mgmt_perf_uploads (user_email, original_filename, fiscal_year, quarter, file_path, file_size, parse_status)
+ VALUES ($1,$2,$3,$4,$5,$6,'ok')
+ RETURNING id`,
+ [userEmail || null, originalFilename, fiscalYear, quarter, filePath, stat.size]
+ );
+ const uploadId = ins.rows[0].id;
+ await pgPool.query(`INSERT INTO mgmt_perf_snapshots (upload_id, payload) VALUES ($1, $2::jsonb)`, [
+ uploadId,
+ JSON.stringify(payload),
+ ]);
+ return { id: uploadId, fileBacked: false };
+}
+
+/**
+ * @param {import("pg").Pool | null} pgPool
+ */
+async function getLatestPayloadRow(pgPool) {
+ if (pgPool) {
+ const r = await pgPool.query(`
+ SELECT s.payload, u.fiscal_year, u.quarter, u.original_filename, u.created_at
+ FROM mgmt_perf_snapshots s
+ JOIN mgmt_perf_uploads u ON u.id = s.upload_id
+ ORDER BY u.created_at DESC
+ LIMIT 1
+ `);
+ if (r.rows[0]) {
+ const row = r.rows[0];
+ return {
+ payload: row.payload,
+ fiscal_year: row.fiscal_year,
+ quarter: row.quarter,
+ original_filename: row.original_filename,
+ created_at: row.created_at,
+ };
+ }
+ }
+ try {
+ const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8"));
+ return {
+ payload: j.payload,
+ fiscal_year: j.meta?.fiscalYear,
+ quarter: j.meta?.quarter,
+ original_filename: j.meta?.originalFilename,
+ created_at: j.meta?.savedAt ? new Date(j.meta.savedAt) : null,
+ };
+ } catch {
+ return {
+ payload: loadDefaultPayload(),
+ fiscal_year: new Date().getFullYear(),
+ quarter: 1,
+ original_filename: null,
+ created_at: null,
+ };
+ }
+}
+
+/**
+ * @param {import("pg").Pool | null} pgPool
+ * @param {number} [limit=20]
+ */
+async function listUploads(pgPool, limit = 20) {
+ if (!pgPool) {
+ try {
+ const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8"));
+ return [
+ {
+ id: "file",
+ original_filename: j.meta?.originalFilename,
+ fiscal_year: j.meta?.fiscalYear,
+ quarter: j.meta?.quarter,
+ created_at: j.meta?.savedAt,
+ parse_status: "ok",
+ },
+ ];
+ } catch {
+ return [];
+ }
+ }
+ const r = await pgPool.query(
+ `SELECT id, original_filename, fiscal_year, quarter, parse_status, created_at
+ FROM mgmt_perf_uploads
+ ORDER BY created_at DESC
+ LIMIT $1`,
+ [limit]
+ );
+ return r.rows;
+}
+
+module.exports = {
+ loadDefaultPayload,
+ buildPayloadFromWorkbook,
+ saveUploadAndSnapshot,
+ getLatestPayloadRow,
+ listUploads,
+ FILE_STATE_PATH,
+};
diff --git a/package.json b/package.json
index 3617e55..fdb5f00 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"nodemailer": "^6.10.1",
"openai": "^6.29.0",
"pg": "^8.20.0",
- "uuid": "^13.0.0"
+ "uuid": "^13.0.0",
+ "xlsx": "^0.18.5"
}
}
diff --git a/public/mgmt-perf/dashboard-app.js b/public/mgmt-perf/dashboard-app.js
new file mode 100644
index 0000000..7778f8a
--- /dev/null
+++ b/public/mgmt-perf/dashboard-app.js
@@ -0,0 +1,910 @@
+(function () {
+ const payloadEl = document.getElementById("mgmt-perf-payload-json");
+ if (!payloadEl || !payloadEl.textContent.trim()) {
+ console.error("mgmt-perf: missing payload");
+ return;
+ }
+ let P;
+ try {
+ P = JSON.parse(payloadEl.textContent);
+ } catch (err) {
+ console.error("mgmt-perf: invalid JSON", err);
+ return;
+ }
+ const UM = P.UM;
+ const ORDER_CAT = P.ORDER_CAT;
+ const CUSTS = P.CUSTS;
+ const RISK_ROWS = P.RISK_ROWS;
+ const ORDER_DATA = P.ORDER_DATA;
+ const MODELS = P.MODELS;
+ const FORECAST = P.FORECAST;
+let state = {
+ currentDivision: 'all',
+ currentMonth: 'all',
+ currentSection: '매출현황',
+ charts: {}
+ };
+
+ // Utility Functions
+ function formatCurrency(value) {
+ return '₩' + (value / 100000000).toFixed(1) + '억';
+ }
+
+ function formatNum(value) {
+ return (value / 100000000).toFixed(2);
+ }
+
+ function getDaysInMonth(month) {
+ const days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ return days[month - 1];
+ }
+
+ // Initialize (iframe에서도 DOMContentLoaded가 이미 지난 경우 대비)
+ function bootMgmtPerfDashboard() {
+ setupEventListeners();
+ renderOverviewMode();
+ renderOrderSection();
+ renderForecastSection();
+ }
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", bootMgmtPerfDashboard);
+ } else {
+ bootMgmtPerfDashboard();
+ }
+
+ function setupEventListeners() {
+ // Division slicer
+ document.querySelectorAll('#divisionSlicer .slicer-tab').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ document.querySelectorAll('#divisionSlicer .slicer-tab').forEach(b => b.classList.remove('active'));
+ e.target.classList.add('active');
+ state.currentDivision = e.target.dataset.division;
+ renderDivisionView();
+ });
+ });
+
+ // Month slicer
+ document.querySelectorAll('#monthSlicer .slicer-tab').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ document.querySelectorAll('#monthSlicer .slicer-tab').forEach(b => b.classList.remove('active'));
+ e.target.classList.add('active');
+ state.currentMonth = e.target.dataset.month;
+ renderDivisionView();
+ });
+ });
+
+ // Section tabs
+ document.querySelectorAll('.section-tab-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ document.querySelectorAll('.section-tab-btn').forEach(b => b.classList.remove('active'));
+ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
+ e.target.classList.add('active');
+ const sectionName = e.target.dataset.section;
+ state.currentSection = sectionName;
+ document.getElementById(sectionName).classList.add('active');
+ });
+ });
+ }
+
+ function destroyChart(chartId) {
+ if (state.charts[chartId]) {
+ state.charts[chartId].destroy();
+ delete state.charts[chartId];
+ }
+ }
+
+ // Division-to-order-key mapping
+ const DIV_TO_ORDER = {
+ fscan: ['fscan'],
+ fscanovs: ['ovsFscan'],
+ xscan: ['xscan'],
+ xscanovs: ['ovsXscan'],
+ battery: ['battery'],
+ newbiz: ['newbiz']
+ };
+
+ function renderDivisionView() {
+ const isOverviewMode = state.currentDivision === 'all';
+ document.getElementById('overviewMode').style.display = isOverviewMode ? 'block' : 'none';
+ document.getElementById('divisionDetailMode').style.display = isOverviewMode ? 'none' : 'block';
+
+ if (isOverviewMode) {
+ renderOverviewMode();
+ } else {
+ renderDivisionDetailMode();
+ }
+ // Hide FSCAN/XSCAN model charts when 배터리 or 신사업 is selected
+ const showModels = !(['battery','newbiz'].includes(state.currentDivision));
+ const modelDisplay = showModels ? 'grid' : 'none';
+ const smr = document.getElementById('salesModelRow');
+ const omr = document.getElementById('orderModelRow');
+ if(smr) smr.style.display = modelDisplay;
+ if(omr) omr.style.display = modelDisplay;
+
+ // Update model charts and category charts for current division/month
+ const mon = state.currentMonth;
+ const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
+ if (showModels) {
+ renderSalesModelCharts(monthIdx);
+ }
+ // Always update category breakdown
+ renderSalesCategoryCharts(monthIdx);
+
+ // Also update 수주현황 and 예상전망
+ renderOrderSection();
+ renderForecastSection();
+ }
+
+ // Helper: get total sales for a division for given month indices
+ function getDivSales(d, monthIdx) {
+ return monthIdx.reduce((s, i) => s + (d.mp[i]||0) + (d.mc[i]||0) + (d.mg[i]||0), 0);
+ }
+
+ function renderOverviewMode() {
+ const divisionKeys = ['fscan', 'fscanovs', 'xscan', 'xscanovs', 'battery', 'newbiz'];
+ const divisions = divisionKeys.map(k => UM[k]);
+ const mon = state.currentMonth;
+ const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
+ const monLabel = mon === 'all' ? 'Q1' : mon + '월';
+
+ // Calculate totals based on selected month
+ const totalSales = divisions.reduce((s, d) => s + getDivSales(d, monthIdx), 0);
+ const totalTgt = mon === 'all'
+ ? divisions.reduce((s, d) => s + d.tgt, 0)
+ : divisions.reduce((s, d) => s + d.tgt / 3, 0); // monthly avg target
+ const achRate = totalTgt > 0 ? (totalSales / totalTgt * 100).toFixed(1) : '0.0';
+
+ // KPI Cards
+ const kpiHtml = `
+
+
${monLabel} 매출
+
${formatNum(totalSales)}
+
억원
+
+
+
${monLabel} 목표
+
${formatNum(totalTgt)}
+
억원
+
+
+
목표달성률
+
${achRate}%
+
${parseFloat(achRate) >= 100 ? '초과달성' : '진행중'}
+
+
+
미발행 잔액
+
${formatNum(UM.all.ub)}
+
리스크관리 필요
+
+ `;
+ document.getElementById('overviewKpis').innerHTML = kpiHtml;
+
+ // Monthly Trend - highlight selected month
+ destroyChart('monthlyTrendChart');
+ const monthlyCtx = document.getElementById('monthlyTrendChart').getContext('2d');
+ state.charts['monthlyTrendChart'] = new Chart(monthlyCtx, {
+ type: 'line',
+ data: {
+ labels: ['1월', '2월', '3월'],
+ datasets: divisionKeys.map(k => {
+ const d = UM[k];
+ const pRadius = [0,1,2].map(i => {
+ if (mon === 'all') return 6;
+ return parseInt(mon)-1 === i ? 10 : 4;
+ });
+ return {
+ label: d.nm.replace(/ (영업|사업)본부/,''),
+ data: d.mp.map(v => v/1e8),
+ borderColor: d.c,
+ backgroundColor: d.c + '15',
+ borderWidth: 3, pointRadius: pRadius, pointBackgroundColor: d.c,
+ tension: 0.3, fill: false
+ };
+ })
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: true, position: 'top' } },
+ scales: { y: { beginAtZero: true } }
+ }
+ });
+
+ // Division Sales - filtered by month
+ destroyChart('divisionSalesChart');
+ const divSalesCtx = document.getElementById('divisionSalesChart').getContext('2d');
+ const divSalesData = divisions.map(d => getDivSales(d, monthIdx) / 1e8);
+ state.charts['divisionSalesChart'] = new Chart(divSalesCtx, {
+ type: 'bar',
+ data: {
+ labels: divisions.map(d => d.nm),
+ datasets: [{
+ label: monLabel + ' 매출',
+ data: divSalesData,
+ backgroundColor: divisions.map(d => d.c),
+ borderRadius: 4
+ }]
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: { x: { beginAtZero: true } }
+ }
+ });
+
+ // Target vs Actual - filtered by month
+ destroyChart('targetVsActualChart');
+ const tgtCtx = document.getElementById('targetVsActualChart').getContext('2d');
+ state.charts['targetVsActualChart'] = new Chart(tgtCtx, {
+ type: 'bar',
+ data: {
+ labels: divisions.map(d => d.nm),
+ datasets: [
+ { label: '목표', data: divisions.map(d => (mon==='all' ? d.tgt : d.tgt/3) / 1e8), backgroundColor: '#ddd', borderRadius: 4 },
+ { label: '실적', data: divisions.map(d => getDivSales(d, monthIdx) / 1e8), backgroundColor: divisions.map(d => d.c), borderRadius: 4 }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: { y: { beginAtZero: true } }
+ }
+ });
+
+ // Achievement Rate - Horizontal Bar (month-aware)
+ destroyChart('achievementRateChart');
+ const achCtx = document.getElementById('achievementRateChart').getContext('2d');
+ const achRates = divisions.map(d => {
+ const tgt = mon === 'all' ? d.tgt : d.tgt / 3;
+ const sales = getDivSales(d, monthIdx);
+ return tgt > 0 ? (sales / tgt * 100) : 0;
+ });
+ state.charts['achievementRateChart'] = new Chart(achCtx, {
+ type: 'bar',
+ data: {
+ labels: divisions.map(d => d.nm),
+ datasets: [{
+ label: '달성률 (%)',
+ data: achRates,
+ backgroundColor: divisions.map((d, i) => achRates[i] >= 100 ? '#cc0000' : '#999'),
+ borderRadius: 4
+ }]
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1) + '%' } }
+ },
+ scales: {
+ x: {
+ beginAtZero: true,
+ ticks: { callback: v => v + '%' },
+ grid: { display: true }
+ },
+ y: { grid: { display: false } }
+ }
+ },
+ plugins: [{
+ afterDatasetsDraw: function(chart) {
+ const ctx2 = chart.ctx;
+ chart.data.datasets[0].data.forEach((val, i) => {
+ const meta = chart.getDatasetMeta(0).data[i];
+ if (meta) {
+ ctx2.fillStyle = '#333';
+ ctx2.font = 'bold 12px sans-serif';
+ ctx2.textAlign = 'left';
+ ctx2.textBaseline = 'middle';
+ ctx2.fillText(val.toFixed(1) + '%', meta.x + 6, meta.y);
+ }
+ });
+ }
+ }]
+ });
+ }
+
+ function renderSalesCategoryCharts(monthIdx) {
+ const div = state.currentDivision;
+ const divKeys = div === 'all' ? ['fscan','fscanovs','xscan','xscanovs','battery','newbiz'] : [div];
+
+ let prodTotal = 0, csTotal = 0, goodsTotal = 0;
+ divKeys.forEach(k => {
+ const d = UM[k];
+ prodTotal += monthIdx.reduce((s,i) => s + (d.mp[i]||0), 0);
+ csTotal += monthIdx.reduce((s,i) => s + (d.mc[i]||0), 0);
+ goodsTotal += monthIdx.reduce((s,i) => s + (d.mg[i]||0), 0);
+ });
+ const total = prodTotal + csTotal + goodsTotal;
+
+ const titleSuffix = div === 'all' ? '' : ' (' + UM[div].nm + ')';
+ document.getElementById('salesCatTitle').textContent = '매출유형별 비중' + titleSuffix;
+ document.getElementById('salesCatBarTitle').textContent = '매출유형별 금액 (단위: 억원)' + titleSuffix;
+
+ // Doughnut chart
+ destroyChart('salesCatChart');
+ const catData = [prodTotal, csTotal, goodsTotal].map(v => v / 1e8);
+ const catColors = ['#cc0000', '#1565C0', '#F57C00'];
+ const catLabels = ['제품매출', 'CS매출', '상품매출'];
+ // Filter out zero values
+ const filtered = catLabels.map((l, i) => ({label: l, value: catData[i], color: catColors[i]})).filter(x => x.value > 0);
+
+ state.charts['salesCatChart'] = new Chart(document.getElementById('salesCatChart').getContext('2d'), {
+ type: 'doughnut',
+ data: {
+ labels: filtered.map(x => x.label),
+ datasets: [{
+ data: filtered.map(x => x.value),
+ backgroundColor: filtered.map(x => x.color),
+ borderWidth: 2, borderColor: '#fff'
+ }]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'bottom', labels: { padding: 15, font: { size: 12 } } },
+ tooltip: { callbacks: { label: ctx => {
+ const pct = total > 0 ? (ctx.raw * 1e8 / total * 100).toFixed(1) : '0';
+ return ctx.label + ': ' + ctx.raw.toFixed(1) + '억 (' + pct + '%)';
+ }}}
+ }
+ }
+ });
+
+ // Bar chart
+ destroyChart('salesCatBarChart');
+ state.charts['salesCatBarChart'] = new Chart(document.getElementById('salesCatBarChart').getContext('2d'), {
+ type: 'bar',
+ data: {
+ labels: filtered.map(x => x.label),
+ datasets: [{
+ data: filtered.map(x => x.value),
+ backgroundColor: filtered.map(x => x.color),
+ borderRadius: 4
+ }]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: { legend: { display: false },
+ tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1) + '억원' } }
+ },
+ scales: { y: { beginAtZero: true, ticks: { callback: v => v + '억' } }, x: { grid: { display: false } } }
+ }
+ });
+ }
+
+ function renderOrderCategoryCharts(monthIdx) {
+ const div = state.currentDivision;
+ const divKeys = div === 'all' ? ['fscan','fscanovs','xscan','xscanovs','battery','newbiz'] : [div];
+
+ let prodTotal = 0, goodsTotal = 0;
+ divKeys.forEach(k => {
+ const d = ORDER_CAT[k];
+ if (d) {
+ prodTotal += monthIdx.reduce((s,i) => s + (d.op[i]||0), 0);
+ goodsTotal += monthIdx.reduce((s,i) => s + (d.og[i]||0), 0);
+ }
+ });
+ const total = prodTotal + goodsTotal;
+
+ const titleSuffix = div === 'all' ? '' : ' (' + UM[div].nm + ')';
+ document.getElementById('orderCatTitle').textContent = '수주유형별 비중' + titleSuffix;
+ document.getElementById('orderCatBarTitle').textContent = '수주유형별 금액 (단위: 억원)' + titleSuffix;
+
+ // Doughnut
+ destroyChart('orderCatChart');
+ const catData = [prodTotal, goodsTotal].map(v => v / 1e8);
+ const catColors = ['#cc0000', '#F57C00'];
+ const catLabels = ['제품수주', '상품수주'];
+ const filtered = catLabels.map((l, i) => ({label: l, value: catData[i], color: catColors[i]})).filter(x => x.value > 0);
+
+ if (filtered.length > 0) {
+ state.charts['orderCatChart'] = new Chart(document.getElementById('orderCatChart').getContext('2d'), {
+ type: 'doughnut',
+ data: {
+ labels: filtered.map(x => x.label),
+ datasets: [{
+ data: filtered.map(x => x.value),
+ backgroundColor: filtered.map(x => x.color),
+ borderWidth: 2, borderColor: '#fff'
+ }]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'bottom', labels: { padding: 15, font: { size: 12 } } },
+ tooltip: { callbacks: { label: ctx => {
+ const pct = total > 0 ? (ctx.raw * 1e8 / total * 100).toFixed(1) : '0';
+ return ctx.label + ': ' + ctx.raw.toFixed(1) + '억 (' + pct + '%)';
+ }}}
+ }
+ }
+ });
+ }
+
+ // Bar
+ destroyChart('orderCatBarChart');
+ if (filtered.length > 0) {
+ state.charts['orderCatBarChart'] = new Chart(document.getElementById('orderCatBarChart').getContext('2d'), {
+ type: 'bar',
+ data: {
+ labels: filtered.map(x => x.label),
+ datasets: [{
+ data: filtered.map(x => x.value),
+ backgroundColor: filtered.map(x => x.color),
+ borderRadius: 4
+ }]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: { legend: { display: false },
+ tooltip: { callbacks: { label: ctx => ctx.raw.toFixed(1) + '억원' } }
+ },
+ scales: { y: { beginAtZero: true, ticks: { callback: v => v + '억' } }, x: { grid: { display: false } } }
+ }
+ });
+ }
+ }
+
+ function renderModelChart(canvasId, models, monthIdx, color) {
+ destroyChart(canvasId);
+ const ctx = document.getElementById(canvasId).getContext('2d');
+ const top6 = models.slice(0, 6);
+ const data = top6.map(m => monthIdx.reduce((s, i) => s + (m.m[i]||0), 0) / 1e8);
+ state.charts[canvasId] = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: top6.map(m => m.model),
+ datasets: [{
+ label: '매출 (억원)',
+ data: data,
+ backgroundColor: top6.map((_, i) => {
+ const opacity = 1 - (i * 0.12);
+ return color + Math.round(opacity * 255).toString(16).padStart(2, '0');
+ }),
+ borderRadius: 4
+ }]
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: { x: { beginAtZero: true }, y: { grid: { display: false } } }
+ }
+ });
+ }
+
+ // Combine two model arrays, re-sort by amt and take top 5
+ function combineModels(a, b) {
+ return [...(a||[]), ...(b||[])].sort((x,y) => y.amt - x.amt).slice(0, 5);
+ }
+
+ function getModelDataForDiv(section, brand) {
+ const div = state.currentDivision;
+ const src = section === 'sales' ? MODELS.sales : MODELS.orders;
+ if (brand === 'fscan') {
+ if (div === 'fscan') return src.fscan || [];
+ if (div === 'fscanovs') return src.fscanovs || [];
+ return combineModels(src.fscan, src.fscanovs); // all or xscan divisions
+ } else {
+ if (div === 'xscan') return src.xscan || [];
+ if (div === 'xscanovs') return src.xscanovs || [];
+ return combineModels(src.xscan, src.xscanovs); // all or fscan divisions
+ }
+ }
+
+ function getModelTitle(brand, section, div) {
+ const sectionLabel = section === 'sales' ? '매출' : '수주';
+ if (brand === 'fscan') {
+ if (div === 'fscan') return 'FSCAN 국내 주요 모델별 ' + sectionLabel + ' Top 5';
+ if (div === 'fscanovs') return 'FSCAN 해외 주요 모델별 ' + sectionLabel + ' Top 5';
+ return 'FSCAN 주요 모델별 ' + sectionLabel + ' Top 5';
+ } else {
+ if (div === 'xscan') return 'XSCAN 국내 주요 모델별 ' + sectionLabel + ' Top 5';
+ if (div === 'xscanovs') return 'XSCAN 해외 주요 모델별 ' + sectionLabel + ' Top 5';
+ return 'XSCAN 주요 모델별 ' + sectionLabel + ' Top 5';
+ }
+ }
+
+ function renderSalesModelCharts(monthIdx) {
+ const div = state.currentDivision;
+ document.getElementById('fscanSalesModelTitle').textContent = getModelTitle('fscan', 'sales', div);
+ document.getElementById('xscanSalesModelTitle').textContent = getModelTitle('xscan', 'sales', div);
+ renderModelChart('fscanModelChart', getModelDataForDiv('sales', 'fscan'), monthIdx, '#cc0000');
+ renderModelChart('xscanModelChart', getModelDataForDiv('sales', 'xscan'), monthIdx, '#2E7D32');
+ }
+
+ function renderOrderModelCharts(monthIdx) {
+ const div = state.currentDivision;
+ document.getElementById('fscanOrderModelTitle').textContent = getModelTitle('fscan', 'orders', div);
+ document.getElementById('xscanOrderModelTitle').textContent = getModelTitle('xscan', 'orders', div);
+ renderModelChart('fscanOrderModelChart', getModelDataForDiv('orders', 'fscan'), monthIdx, '#cc0000');
+ renderModelChart('xscanOrderModelChart', getModelDataForDiv('orders', 'xscan'), monthIdx, '#2E7D32');
+ }
+
+ function renderDivisionDetailMode() {
+ const div = UM[state.currentDivision];
+ if (!div) return;
+ const mon = state.currentMonth;
+ const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
+ const monLabel = mon === 'all' ? 'Q1' : mon + '월';
+
+ const customers = CUSTS[state.currentDivision] || [];
+ const totalCust = customers.reduce((s, c) => s + c.amt, 0);
+
+ // Detail KPIs - month aware
+ const divSales = getDivSales(div, monthIdx);
+ const divTgt = mon === 'all' ? div.tgt : div.tgt / 3;
+ const achieveRate = divTgt > 0 ? (divSales / divTgt * 100).toFixed(1) : '0.0';
+ const domPct = div.q1 > 0 ? ((div.dom / div.q1) * 100).toFixed(1) : '0.0';
+ const ovsPct = div.q1 > 0 ? ((div.ovs / div.q1) * 100).toFixed(1) : '0.0';
+ const prevMonthGrowth = div.mp[2] > 0 && div.mp[1] > 0 ? ((div.mp[2] / div.mp[1] - 1) * 100).toFixed(1) : 'N/A';
+
+ const detailKpis = `
+
+
${monLabel} 매출
+
${formatNum(divSales)}
+
억원
+
+
+
목표달성률
+
${achieveRate}%
+
${parseFloat(achieveRate) >= 100 ? '초과' : '진행중'}
+
+
+
국내/해외 비중
+
${domPct}% / ${ovsPct}%
+
국내 / 해외
+
+
+
미발행 잔액
+
${formatNum(div.ub)}
+
억원
+
+
+
전월비 성장률
+
${prevMonthGrowth}%
+
3월 vs 2월
+
+ `;
+ document.getElementById('divisionDetailKpis').innerHTML = detailKpis;
+
+ // Monthly Trend for Division
+ destroyChart('divisionMonthlyChart');
+ const monthCtx = document.getElementById('divisionMonthlyChart').getContext('2d');
+ state.charts['divisionMonthlyChart'] = new Chart(monthCtx, {
+ type: 'bar',
+ data: {
+ labels: ['1월', '2월', '3월'],
+ datasets: [{
+ label: div.nm,
+ data: [div.mp[0]/100000000, div.mp[1]/100000000, div.mp[2]/100000000],
+ backgroundColor: div.c
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: { y: { beginAtZero: true } }
+ }
+ });
+
+ // Customer Share
+ destroyChart('customerShareChart');
+ const custCtx = document.getElementById('customerShareChart').getContext('2d');
+ const topCustomers = customers.slice(0, 5);
+ state.charts['customerShareChart'] = new Chart(custCtx, {
+ type: 'doughnut',
+ data: {
+ labels: topCustomers.map(c => c.nm),
+ datasets: [{
+ data: topCustomers.map(c => (c.amt / totalCust * 100).toFixed(1)),
+ backgroundColor: ['#cc0000', '#1565C0', '#2E7D32', '#F57C00', '#7B1FA2']
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: true, position: 'bottom' } }
+ }
+ });
+
+ // Customer Table
+ const custTable = document.getElementById('customerTable').querySelector('tbody');
+ custTable.innerHTML = customers.map((c, i) => `
+
+ | ${i+1} |
+ ${c.nm} |
+ ${c.gbn} |
+ ${c.acc} |
+ ${formatCurrency(c.amt)} |
+ ${(c.amt / totalCust * 100).toFixed(1)}% |
+ ${formatCurrency(c.ub)} |
+
+ `).join('');
+
+ // Risk Section
+ const riskTotal = customers.reduce((s, c) => s + c.ub, 0);
+ document.getElementById('divisionRiskTotal').textContent = formatCurrency(riskTotal);
+
+ // Risk by Customer
+ const riskCustomers = customers.filter(c => c.ub > 0).sort((a, b) => b.ub - a.ub);
+ destroyChart('riskByCustomerChart');
+ const riskCtx = document.getElementById('riskByCustomerChart').getContext('2d');
+ state.charts['riskByCustomerChart'] = new Chart(riskCtx, {
+ type: 'bar',
+ data: {
+ labels: riskCustomers.map(c => c.nm.substring(0, 15)),
+ datasets: [{
+ label: '미발행금액',
+ data: riskCustomers.map(c => c.ub / 100000000),
+ backgroundColor: '#ff9800'
+ }]
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: { x: { beginAtZero: true } }
+ }
+ });
+
+ // Risk Detail Table
+ const riskTable = document.getElementById('riskDetailTable').querySelector('tbody');
+ const filteredRisk = RISK_ROWS.filter(r => {
+ const divMap = {'FSCAN국내': 'fscan', 'FSCAN해외': 'fscanovs', 'XSCAN': 'xscan', 'XSCAN해외': 'xscanovs', '배터리': 'battery', '신사업': 'newbiz'};
+ return divMap[r.본] === state.currentDivision;
+ });
+ riskTable.innerHTML = filteredRisk.map(r => `
+
+ | ${r.월}월 |
+ ${r.본} |
+ ${r.고} |
+ ${r.품} |
+ ${formatCurrency(r.amt)} |
+
+ `).join('');
+ }
+
+ function renderOrderSection() {
+ const div = state.currentDivision;
+ const mon = state.currentMonth;
+ const divData = ORDER_DATA.divisions;
+ const monthIdx = mon === 'all' ? [0,1,2] : [parseInt(mon)-1];
+ const monthLabels = mon === 'all' ? ['1월','2월','3월'] : [mon + '월'];
+
+ // Filter divisions by slicer
+ let filteredDivKeys;
+ if (div === 'all') {
+ filteredDivKeys = Object.keys(divData);
+ } else {
+ filteredDivKeys = DIV_TO_ORDER[div] || [];
+ }
+
+ // Calculate aggregated actual/plan for filtered divisions & months
+ let totalActual = 0, totalPlan = 0;
+ filteredDivKeys.forEach(k => {
+ const d = divData[k];
+ if (d) monthIdx.forEach(i => { totalActual += d.actual[i]; totalPlan += d.plan[i]; });
+ });
+
+ // Monthly aggregates for chart
+ const monthlyActual = [0,1,2].map(i => {
+ let s = 0; filteredDivKeys.forEach(k => { if(divData[k]) s += divData[k].actual[i]; }); return s;
+ });
+ const monthlyPlan = [0,1,2].map(i => {
+ let s = 0; filteredDivKeys.forEach(k => { if(divData[k]) s += divData[k].plan[i]; }); return s;
+ });
+
+ const divLabel = div === 'all' ? '전체' : UM[div].nm;
+ const monLabel = mon === 'all' ? 'Q1' : mon + '월';
+
+ // KPIs
+ const achRate = totalPlan > 0 ? (totalActual / totalPlan * 100).toFixed(1) : '0.0';
+ const kpis = `
+
+
${monLabel} 수주 실적
+
${totalActual.toFixed(2)}
+
억원 ${div !== 'all' ? '| ' + divLabel : ''}
+
+
+
${monLabel} 계획
+
${totalPlan.toFixed(2)}
+
억원
+
+
+
달성률
+
${achRate}%
+
계획 대비
+
+
+
GAP
+
${(totalActual - totalPlan).toFixed(2)}
+
억원 ${totalActual >= totalPlan ? '초과' : '부족'}
+
+ `;
+ document.getElementById('orderKpis').innerHTML = kpis;
+
+ // Monthly Order Chart (always show all 3 months, highlight selected)
+ destroyChart('orderMonthlyChart');
+ const monthCtx = document.getElementById('orderMonthlyChart').getContext('2d');
+ const barBgActual = ['1월','2월','3월'].map((m,i) => {
+ if (mon === 'all' || parseInt(mon)-1 === i) return '#cc0000';
+ return 'rgba(204,0,0,0.25)';
+ });
+ const barBgPlan = ['1월','2월','3월'].map((m,i) => {
+ if (mon === 'all' || parseInt(mon)-1 === i) return '#ccc';
+ return 'rgba(204,204,204,0.25)';
+ });
+ state.charts['orderMonthlyChart'] = new Chart(monthCtx, {
+ type: 'bar',
+ data: {
+ labels: ['1월','2월','3월'],
+ datasets: [
+ { label: '실적', data: monthlyActual, backgroundColor: barBgActual, borderRadius: 4 },
+ { label: '계획', data: monthlyPlan, backgroundColor: barBgPlan, borderRadius: 4 }
+ ]
+ },
+ options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
+ });
+
+ // Division Order Chart
+ destroyChart('orderByDivisionChart');
+ const divCtx = document.getElementById('orderByDivisionChart').getContext('2d');
+ const divColors = {fscan:'#cc0000', xscan:'#2E7D32', ovsFscan:'#1565C0', ovsXscan:'#0D47A1', battery:'#F57C00', newbiz:'#7B1FA2'};
+ const showDivKeys = div === 'all' ? Object.keys(divData) : filteredDivKeys;
+ state.charts['orderByDivisionChart'] = new Chart(divCtx, {
+ type: 'bar',
+ data: {
+ labels: showDivKeys.map(k => divData[k].nm),
+ datasets: [{
+ label: monLabel + ' 실적',
+ data: showDivKeys.map(k => {
+ let s = 0; monthIdx.forEach(i => { s += divData[k].actual[i]; }); return s;
+ }),
+ backgroundColor: showDivKeys.map(k => divColors[k] || '#cc0000'),
+ borderRadius: 4
+ },{
+ label: monLabel + ' 계획',
+ data: showDivKeys.map(k => {
+ let s = 0; monthIdx.forEach(i => { s += divData[k].plan[i]; }); return s;
+ }),
+ backgroundColor: showDivKeys.map(() => '#ddd'),
+ borderRadius: 4
+ }]
+ },
+ options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, scales: { x: { beginAtZero: true } } }
+ });
+
+ // Detail Table
+ const detailTable = document.getElementById('orderDetailTable').querySelector('tbody');
+ detailTable.innerHTML = showDivKeys.map(key => {
+ const d = divData[key];
+ const q1 = d.actual[0] + d.actual[1] + d.actual[2];
+ const planQ1 = d.plan[0] + d.plan[1] + d.plan[2];
+ const rate = planQ1 > 0 ? (q1 / planQ1 * 100).toFixed(1) : '-';
+ const isSel = mon !== 'all';
+ return `
+
+ | ${d.nm} |
+ ${d.actual[0].toFixed(2)} |
+ ${d.actual[1].toFixed(2)} |
+ ${d.actual[2].toFixed(2)} |
+ ${q1.toFixed(2)} |
+ ${planQ1.toFixed(2)} |
+ ${rate}% |
+
+ `;
+ }).join('');
+
+ // Order Model Charts — division-aware
+ renderOrderModelCharts(monthIdx);
+
+ // Order Category Charts (제품수주/상품수주)
+ renderOrderCategoryCharts(monthIdx);
+ }
+
+ function renderForecastSection() {
+ const div = state.currentDivision;
+ const divData = ORDER_DATA.divisions;
+
+ // If a specific division is selected, compute division-level forecast
+ let planData, actualData, bepVal, annPlan, annBep;
+ if (div === 'all') {
+ planData = FORECAST.plan;
+ actualData = FORECAST.actual;
+ bepVal = FORECAST.bep;
+ annPlan = FORECAST.annualPlanTotal;
+ annBep = FORECAST.annualBepTotal;
+ } else {
+ const keys = DIV_TO_ORDER[div] || [];
+ // Build monthly plan/actual from division order data (6 months available)
+ planData = Array(12).fill(0);
+ actualData = Array(12).fill(0);
+ keys.forEach(k => {
+ const d = divData[k];
+ if (d) {
+ for (let i = 0; i < 6; i++) { planData[i] += d.plan[i]; actualData[i] += d.actual[i]; }
+ }
+ });
+ annPlan = planData.reduce((s,v) => s+v, 0);
+ annBep = annPlan * (FORECAST.annualBepTotal / FORECAST.annualPlanTotal);
+ bepVal = annBep / 12;
+ }
+
+ const divLabel = div === 'all' ? '전체' : UM[div].nm;
+ const annualActual = actualData.reduce((s, v) => s + v, 0);
+ const achRate = annPlan > 0 ? (annualActual / annPlan * 100).toFixed(1) : '0.0';
+
+ const kpis = `
+
+
연간 계획 ${div !== 'all' ? '| ' + divLabel : ''}
+
${annPlan.toFixed(2)}
+
억원
+
+
+
손익분기점
+
${annBep.toFixed(2)}
+
억원
+
+
+
누적 실적 (Q1)
+
${annualActual.toFixed(2)}
+
억원
+
+
+
연간 달성률
+
${achRate}%
+
계획 대비
+
+ `;
+ document.getElementById('forecastKpis').innerHTML = kpis;
+
+ // Annual Forecast Chart
+ destroyChart('annualForecastChart');
+ const fcCtx = document.getElementById('annualForecastChart').getContext('2d');
+ const months = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
+
+ // Highlight selected month if any
+ const mon = state.currentMonth;
+ const pointRadius = months.map((m,i) => {
+ if (mon === 'all') return 3;
+ return parseInt(mon)-1 === i ? 8 : 3;
+ });
+
+ state.charts['annualForecastChart'] = new Chart(fcCtx, {
+ type: 'line',
+ data: {
+ labels: months,
+ datasets: [
+ { label: '계획', data: planData, borderColor: '#999', backgroundColor: 'rgba(153,153,153,0.1)', fill: true, tension: 0.4, pointRadius: 3 },
+ { label: '손익분기점', data: Array(12).fill(bepVal), borderColor: '#ff9800', fill: false, borderDash: [5, 5], tension: 0, pointRadius: 0 },
+ { label: '실적', data: actualData, borderColor: '#cc0000', backgroundColor: 'rgba(204,0,0,0.1)', fill: true, tension: 0.4, pointRadius: pointRadius, borderWidth: 3 }
+ ]
+ },
+ options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
+ });
+
+ // Forecast Table
+ const fcTable = document.getElementById('forecastTable').querySelector('tbody');
+ fcTable.innerHTML = months.map((m, i) => {
+ const achieved = actualData[i] > 0 && planData[i] > 0 ? (actualData[i] / planData[i] * 100).toFixed(1) : '-';
+ const isSelected = mon !== 'all' && parseInt(mon)-1 === i;
+ return `
+
+ | ${m} |
+ ${planData[i].toFixed(2)} |
+ ${bepVal.toFixed(2)} |
+ ${actualData[i].toFixed(2)} |
+ ${achieved}${achieved !== '-' ? '%' : ''} |
+
+ `;
+ }).join('');
+ }
+
+})();
\ No newline at end of file
diff --git a/public/mgmt-perf/dashboard.css b/public/mgmt-perf/dashboard.css
new file mode 100644
index 0000000..6f844bf
--- /dev/null
+++ b/public/mgmt-perf/dashboard.css
@@ -0,0 +1,399 @@
+
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ :root {
+ --red: #cc0000;
+ --red2: #e80000;
+ --redd: #8b0000;
+ --blk: #1a1a1a;
+ --blk2: #2d2d2d;
+ --gray-light: #f5f5f5;
+ --gray-mid: #e8e8e8;
+ --gray-dark: #666666;
+ --gold: #d4af37;
+ --orange: #ff9800;
+ }
+
+ body {
+ font-family: '맑은 고딕', 'Apple SD Gothic Neo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ background: linear-gradient(135deg, var(--blk) 0%, var(--blk2) 100%);
+ color: var(--blk);
+ line-height: 1.6;
+ padding: 20px;
+ }
+
+ .container {
+ max-width: 1600px;
+ margin: 0 auto;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+ overflow: hidden;
+ }
+
+ /* Header */
+ .header {
+ background: linear-gradient(135deg, var(--redd) 0%, var(--red) 100%);
+ color: white;
+ padding: 40px 30px;
+ border-bottom: 4px solid var(--red2);
+ }
+
+ .header h1 {
+ font-size: 32px;
+ margin-bottom: 5px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ }
+
+ .header p {
+ font-size: 14px;
+ opacity: 0.95;
+ font-weight: 300;
+ }
+
+ /* Slicer Bars Container */
+ .slicers-container {
+ display: flex;
+ gap: 30px;
+ padding: 20px 30px;
+ background: var(--gray-light);
+ border-bottom: 1px solid var(--gray-mid);
+ align-items: flex-start;
+ }
+
+ .slicer-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .slicer-label {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--blk);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .slicer-tabs {
+ display: flex;
+ gap: 4px;
+ }
+
+ .slicer-tab {
+ padding: 8px 14px;
+ background: white;
+ border: 1px solid var(--gray-mid);
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ color: var(--blk);
+ }
+
+ .slicer-tab:hover {
+ background: var(--gray-mid);
+ }
+
+ .slicer-tab.active {
+ background: var(--red);
+ color: white;
+ border-color: var(--red);
+ box-shadow: 0 2px 6px rgba(204, 0, 0, 0.3);
+ }
+
+ /* Section Tabs Navigation */
+ .section-tabs {
+ display: flex;
+ background: white;
+ border-bottom: 2px solid var(--gray-mid);
+ padding: 0;
+ gap: 0;
+ }
+
+ .section-tab-btn {
+ flex: 1;
+ padding: 16px 20px;
+ background: white;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--blk);
+ transition: all 0.3s ease;
+ border-bottom: 3px solid transparent;
+ max-width: 200px;
+ }
+
+ .section-tab-btn:hover {
+ background: var(--gray-light);
+ color: var(--red);
+ }
+
+ .section-tab-btn.active {
+ background: white;
+ color: var(--red);
+ border-bottom-color: var(--red);
+ }
+
+ /* Content Area */
+ .content {
+ padding: 30px;
+ min-height: 600px;
+ }
+
+ .section {
+ display: none;
+ }
+
+ .section.active {
+ display: block;
+ animation: fadeIn 0.3s ease;
+ }
+
+ @keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+
+ /* KPI Cards */
+ .kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+ }
+
+ .kpi-card {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ border-top: 4px solid var(--red);
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+ }
+
+ .kpi-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+
+ .kpi-card.warning {
+ border-top-color: var(--orange);
+ background: rgba(255, 152, 0, 0.05);
+ }
+
+ .kpi-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--gray-dark);
+ text-transform: uppercase;
+ margin-bottom: 8px;
+ letter-spacing: 0.5px;
+ }
+
+ .kpi-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--red);
+ margin-bottom: 4px;
+ }
+
+ .kpi-value.large {
+ font-size: 32px;
+ }
+
+ .kpi-subtext {
+ font-size: 11px;
+ color: var(--gray-dark);
+ margin-top: 8px;
+ }
+
+ /* Charts */
+ .chart-container {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 30px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ position: relative;
+ }
+
+ .chart-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--blk);
+ margin-bottom: 20px;
+ }
+
+ .chart-wrapper {
+ position: relative;
+ height: 300px;
+ width: 100%;
+ }
+
+ .chart-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+ margin-bottom: 30px;
+ }
+
+ /* Tables */
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ background: white;
+ }
+
+ thead {
+ background: var(--gray-light);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ }
+
+ th {
+ padding: 12px 8px;
+ text-align: left;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--blk);
+ border-bottom: 2px solid var(--gray-mid);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ td {
+ padding: 12px 8px;
+ font-size: 12px;
+ border-bottom: 1px solid var(--gray-mid);
+ }
+
+ tbody tr:hover {
+ background: var(--gray-light);
+ }
+
+ .rank-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--red);
+ color: white;
+ font-size: 11px;
+ font-weight: 700;
+ }
+
+ .num-cell {
+ text-align: right;
+ font-family: 'Courier New', monospace;
+ }
+
+ .percent-cell {
+ text-align: right;
+ color: var(--red);
+ font-weight: 600;
+ }
+
+ /* Table Wrapper */
+ .table-wrapper {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 30px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ overflow-x: auto;
+ }
+
+ .table-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--blk);
+ margin-bottom: 15px;
+ }
+
+ /* Risk Section */
+ .risk-section {
+ background: linear-gradient(135deg, rgba(255, 152, 0, 0.05) 0%, rgba(212, 175, 55, 0.05) 100%);
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 30px;
+ border-left: 4px solid var(--orange);
+ }
+
+ .risk-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--orange);
+ margin-bottom: 20px;
+ }
+
+ .risk-value {
+ font-size: 36px;
+ font-weight: 700;
+ color: var(--orange);
+ }
+
+ .risk-label {
+ font-size: 12px;
+ color: var(--gray-dark);
+ margin-top: 5px;
+ }
+
+ /* Division Detail View */
+ .division-detail {
+ display: none;
+ }
+
+ .division-detail.active {
+ display: block;
+ animation: fadeIn 0.3s ease;
+ }
+
+ /* Responsive */
+ @media (max-width: 1200px) {
+ .chart-row {
+ grid-template-columns: 1fr;
+ }
+
+ .slicers-container {
+ flex-direction: column;
+ gap: 15px;
+ }
+ }
+
+ @media (max-width: 768px) {
+ .kpi-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .section-tab-btn {
+ max-width: none;
+ }
+ }
+
+ @media print {
+ body {
+ background: white;
+ padding: 0;
+ }
+
+ .container {
+ box-shadow: none;
+ border-radius: 0;
+ }
+
+ .section {
+ page-break-inside: avoid;
+ }
+ }
+
\ No newline at end of file
diff --git a/server.js b/server.js
index 4906294..d153258 100644
--- a/server.js
+++ b/server.js
@@ -26,6 +26,7 @@ const {
isOpsStateSuper,
} = require("./lib/ops-state");
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
+const mgmtPerf = require("./lib/mgmt-perf");
const app = express();
const PORT = process.env.PORT || 8030;
@@ -535,6 +536,31 @@ const uploadMeetingAudio = multer({
},
});
+const MGMT_PERF_UPLOAD_DIR = path.join(ROOT_DIR, "uploads", "mgmt-perf");
+const mgmtPerfStorage = multer.diskStorage({
+ destination: (_, __, cb) => {
+ fsSync.mkdirSync(MGMT_PERF_UPLOAD_DIR, { recursive: true });
+ cb(null, MGMT_PERF_UPLOAD_DIR);
+ },
+ filename: (_, file, cb) => {
+ const ext = (path.extname(file.originalname) || ".xlsx").toLowerCase();
+ const safeExt = ext === ".xlsx" ? ".xlsx" : ".xlsx";
+ cb(null, `${Date.now()}-${uuidv4()}${safeExt}`);
+ },
+});
+const uploadMgmtPerfExcel = multer({
+ storage: mgmtPerfStorage,
+ limits: { fileSize: 55 * 1024 * 1024 },
+ fileFilter: (_, file, cb) => {
+ const ext = path.extname(file.originalname).toLowerCase();
+ if (ext !== ".xlsx") {
+ cb(new Error("Excel (.xlsx) 파일만 업로드할 수 있습니다."));
+ return;
+ }
+ cb(null, true);
+ },
+});
+
const mapRowToAxAssignment = (row) => ({
id: row.id,
department: row.department || "",
@@ -1130,6 +1156,30 @@ app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR));
app.use(opsAuth.middleware);
opsAuth.registerRoutes(app);
+app.post("/api/mgmt-perf/upload", uploadMgmtPerfExcel.single("file"), async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: "파일이 없습니다." });
+ const fiscalYear = Math.min(2100, Math.max(2020, parseInt(req.body.fiscalYear, 10) || new Date().getFullYear()));
+ const quarter = Math.min(4, Math.max(1, parseInt(req.body.quarter, 10) || 1));
+ const email = res.locals.opsUserEmail ? String(res.locals.opsUserEmail).trim() : null;
+ const defaultPayload = mgmtPerf.loadDefaultPayload();
+ const buf = fsSync.readFileSync(req.file.path);
+ const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload);
+ await mgmtPerf.saveUploadAndSnapshot(pgPool, {
+ userEmail: email,
+ originalFilename: req.file.originalname,
+ filePath: req.file.path,
+ fiscalYear,
+ quarter,
+ payload,
+ });
+ res.json({ ok: true, message: "저장되었습니다. 아래 대시보드가 갱신되었습니다." });
+ } catch (err) {
+ console.error("mgmt-perf upload:", err);
+ res.status(500).json({ error: err.message || "처리 실패" });
+ }
+});
+
const pageRouter = express.Router();
pageRouter.get("/chat", (req, res) =>
res.render("chat", {
@@ -1174,13 +1224,41 @@ pageRouter.get("/dashboard", (req, res) =>
opsUserEmail: !!res.locals.opsUserEmail,
})
);
-pageRouter.get("/dashboard/business-performance", (req, res) =>
- res.render("dashboard-business-performance", {
- activeMenu: "dashboard",
- adminMode: res.locals.adminMode,
- opsUserEmail: !!res.locals.opsUserEmail,
- })
-);
+pageRouter.get("/dashboard/business-performance", async (req, res, next) => {
+ try {
+ const latest = await mgmtPerf.getLatestPayloadRow(pgPool);
+ const uploadHistory = await mgmtPerf.listUploads(pgPool, 12);
+ const y = latest.fiscal_year || new Date().getFullYear();
+ const q = latest.quarter || 1;
+ res.render("dashboard-business-performance", {
+ activeMenu: "dashboard",
+ adminMode: res.locals.adminMode,
+ opsUserEmail: !!res.locals.opsUserEmail,
+ defaultYear: y,
+ selectedQuarter: q,
+ uploadHistory,
+ });
+ } catch (err) {
+ next(err);
+ }
+});
+pageRouter.get("/dashboard/business-performance/embed", async (req, res, next) => {
+ try {
+ const row = await mgmtPerf.getLatestPayloadRow(pgPool);
+ const fy = row.fiscal_year || new Date().getFullYear();
+ const q = row.quarter || 1;
+ const payloadJson = JSON.stringify(row.payload).replace(/ {
diff --git a/views/dashboard-business-performance.ejs b/views/dashboard-business-performance.ejs
index c9f5520..a6fd94e 100644
--- a/views/dashboard-business-performance.ejs
+++ b/views/dashboard-business-performance.ejs
@@ -6,6 +6,112 @@
<%- include('partials/favicon') %>
경영성과 대시보드 - XAVIS
+
@@ -13,7 +119,7 @@
activeMenu: 'dashboard',
adminMode: typeof adminMode !== 'undefined' ? adminMode : false,
}) %>
-
+
@@ -21,14 +127,116 @@
← 대시보드 목록으로
-
-
- 이 화면은 향후 경영·성과 지표 연동 및 위젯 구성을 위한 진입점입니다. 필요한 데이터 소스와 차트
- 요구사항이 정해지면 이어서 구현할 수 있습니다.
-
-
+
+
+
+ 엑셀 업로드
+
+ 매출 집계 엑셀(매출일보 시트 포함)을 업로드하면 스냅샷이 저장되고, 아래 대시보드에 반영됩니다.
+
+
+
+ <% if (typeof uploadHistory !== 'undefined' && uploadHistory && uploadHistory.length) { %>
+
+
최근 업로드
+
+
+
+ | 일시 |
+ 파일명 |
+ 연도·분기 |
+
+
+
+ <% uploadHistory.forEach(function (u) { %>
+
+ | <%= u.created_at ? new Date(u.created_at).toLocaleString('ko-KR') : '-' %> |
+ <%= u.original_filename || '-' %> |
+ <%= u.fiscal_year %>년 Q<%= u.quarter %> |
+
+ <% }); %>
+
+
+
+ <% } %>
+
+
+
+
+