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 %>
+
+ <% } %> +
+ +
+

대시보드 조회

+
+ +
+
+
+ diff --git a/views/mgmt_perf_embed.ejs b/views/mgmt_perf_embed.ejs new file mode 100644 index 0000000..c574a69 --- /dev/null +++ b/views/mgmt_perf_embed.ejs @@ -0,0 +1,15 @@ + + + + + + <%= dashboardTitle %> + + + + + <%- include('partials/mgmt_perf_dashboard_container', { dashboardTitle, quarterLabel }) %> + + + + diff --git a/views/partials/mgmt_perf_dashboard_container.ejs b/views/partials/mgmt_perf_dashboard_container.ejs new file mode 100644 index 0000000..1cc8467 --- /dev/null +++ b/views/partials/mgmt_perf_dashboard_container.ejs @@ -0,0 +1,267 @@ +
+
+

<%= typeof dashboardTitle !== 'undefined' ? dashboardTitle : '경영성과 대시보드' %>

+

실시간 성과 분석 및 사업본부별 상세 현황

+
+ + +
+
+
사업본부 (Division)
+
+ + + + + + + +
+
+ +
+
월별 (Month)
+
+ + + + +
+
+
+ + +
+ + + +
+ + +
+ +
+ +
+
+
+
+
월별 매출 추이
+
+ +
+
+
+
사업본부별 매출 현황
+
+ +
+
+
+
+
+
목표 대비 실적
+
+ +
+
+
+
사업본부별 목표달성률
+
+ +
+
+
+
+ + + + +
+
+
매출유형별 비중
+
+ +
+
+
+
매출유형별 금액 (단위: 억원)
+
+ +
+
+
+ +
+
+
FSCAN 주요 모델별 매출 Top 5
+
+ +
+
+
+
XSCAN 주요 모델별 매출 Top 5
+
+ +
+
+
+
+ + +
+
+
+
+
월별 수주 현황 (단위: 억원)
+
+ +
+
+
+
사업본부별 수주 실적
+
+ +
+
+
+
+
+
수주유형별 비중
+
+ +
+
+
+
수주유형별 금액 (단위: 억원)
+
+ +
+
+
+
+
+
FSCAN 주요 모델별 수주
+
+ +
+
+
+
XSCAN 주요 모델별 수주
+
+ +
+
+
+
+
사업본부별 월별 수주 현황 (단위: 억원)
+ + + + + + + + + + + + + +
사업본부1월2월3월합계계획달성률
+
+
+ + +
+
+
+
3개월 예상실적 (단위: 억원)
+
+ +
+
+
+
월별 전망치
+ + + + + + + + + + + +
계획(억원)손익분기점(억원)실적(억원)달성률
+
+
+
+
+ + \ No newline at end of file