diff --git a/package-lock.json b/package-lock.json index 2a385813..ada4b46f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "sma-next-project", "version": "0.1.0", "dependencies": { + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.0.0", + "@capacitor/push-notifications": "^8.0.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -77,6 +80,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@capacitor/app": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.0.tgz", + "integrity": "sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.0.0.tgz", + "integrity": "sha512-250HTVd/W/KdMygoqaedisvNbHbpbQTN2Hy/8ZYGm1nAqE0Fx7sGss4l0nDg33STxEdDhtVRoL2fIaaiukKseA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.0.tgz", + "integrity": "sha512-xJWQLqAfC8b2ETqAPmwDnkKB4t/lVrbYc2D8VpA2fSu10JFSL/R722Vk0Lfl9Lo9WusmyIiQbVfILNQ3iFNGKw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", diff --git a/package.json b/package.json index 7fc1777b..1b36a3df 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "test:e2e:headed": "playwright test --headed" }, "dependencies": { + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.0.0", + "@capacitor/push-notifications": "^8.0.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/public/sounds/default.wav b/public/sounds/default.wav new file mode 100644 index 00000000..e69de29b diff --git a/public/sounds/push_notification.wav b/public/sounds/push_notification.wav new file mode 100644 index 00000000..e69de29b diff --git a/src/components/production/ProductionDashboard/mockData.ts b/src/components/production/ProductionDashboard/mockData.ts deleted file mode 100644 index 5c9b775d..00000000 --- a/src/components/production/ProductionDashboard/mockData.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { WorkOrder, WorkerStatus, ProcessType } from './types'; - -// Mock 작업 지시 데이터 -export const generateMockWorkOrders = (): WorkOrder[] => { - const processes: ProcessType[] = ['screen', 'slat', 'bending']; - const clients = ['삼성물산(주)', '현대건설(주)', '대림건설(주)', '두산건설(주)', '(주)서울인테리어']; - const projects = [ - '강남 타워 신축현장 (B동)', - '강남 오피스 A동', - '해운대 타워', - '[E2E테스트] 강남 오피스 A동', - '대치 레이크파크', - '위례 청라 센트럴파크', - '판교 물류센터', - '삼성타운 종합', - '분당 더 피스트', - '연수 오피스텔', - ]; - const productNames = [ - '스크린 서터 (표준형) - 추가', - '방연셔터 절곡 부품', - '철재 슬랫 서터', - '스크린 서터 (대형)', - ]; - const assigneePool = [ - '김스크린', '박스크린', '이스크린', '최스크린', - '김슬랫', '박슬랫', '이절곡', '박절곡', - '이정곡', '김술랫', '박술랫', '이슬랫', - ]; - - const orders: WorkOrder[] = []; - - // 긴급 작업 (5개) - WorkOrders mockData와 매칭 - const urgentOrders = [ - { orderNo: 'KD-WO-251217-12', process: 'screen' as ProcessType, client: '두산건설(주)', project: '위브 청라 센트럴파크', dueDate: '2025-12-30', status: 'completed' as const }, - { orderNo: 'KD-WO-251217-11', process: 'screen' as ProcessType, client: '대영건설(주)', project: '대시앙 동탄 레이크파크', dueDate: '2026-02-08', status: 'inProgress' as const }, - { orderNo: 'KD-WO-FLD-251216-01', process: 'bending' as ProcessType, client: '삼성물산(주)', project: '[E2E테스트] 절곡 전용 현장', dueDate: '2025-12-28', status: 'inProgress' as const }, - { orderNo: 'KD-WO-251217-10', process: 'screen' as ProcessType, client: '포레나', project: '포레나 수지 더 퍼스트', dueDate: '2026-02-13', status: 'waiting' as const }, - { orderNo: 'KD-WO-251217-09', process: 'slat' as ProcessType, client: '호반건설(주)', project: '써밋 광교 리버파크', dueDate: '2026-01-30', status: 'inProgress' as const }, - ]; - - urgentOrders.forEach((item, i) => { - orders.push({ - id: `urgent-${i + 1}`, - orderNo: item.orderNo, - productName: productNames[i % productNames.length], - process: item.process, - client: item.client, - projectName: item.project, - assignees: [assigneePool[i % assigneePool.length]], - quantity: (i % 5) + 2, // 고정값 (2~6) - dueDate: item.dueDate, - priority: i + 1, - status: item.status, - isUrgent: true, - isDelayed: false, - instruction: i === 0 ? '추가분 주문, 기존 납품분과 동일 사양 유지' : undefined, - createdAt: '2025-12-20T09:00:00.000Z', // 고정값 - }); - }); - - // 지연 작업 (5개) - WorkOrders mockData와 매칭 - const delayedOrders = [ - { orderNo: 'KD-WO-251217-08', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 5 }, - { orderNo: 'KD-WO-251217-07', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 3 }, - { orderNo: 'KD-WO-FLD-251215-01', process: 'bending' as ProcessType, client: '삼성물산(주)', delayDays: 7 }, - { orderNo: 'KD-WO-FLD-251212-01', process: 'bending' as ProcessType, client: '삼성물산(주)', delayDays: 10 }, - { orderNo: 'KD-WO-FLD-251208-01', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 1 }, - ]; - - delayedOrders.forEach((item, i) => { - orders.push({ - id: `delayed-${i + 1}`, - orderNo: item.orderNo, - productName: productNames[i % productNames.length], - process: item.process, - client: item.client, - projectName: projects[i % projects.length], - assignees: [assigneePool[(i + 5) % assigneePool.length], assigneePool[(i + 6) % assigneePool.length]], - quantity: (i % 5) + 2, // 고정값 (2~6) - dueDate: '2025-01-15', - priority: i + 1, - status: 'inProgress', - isUrgent: false, - isDelayed: true, - delayDays: item.delayDays, - createdAt: '2025-12-20T09:00:00.000Z', // 고정값 - }); - }); - - // 일반 작업 (추가) - 대기/작업중 상태 위주로 추가 - for (let i = 0; i < 22; i++) { - const process = processes[i % 3]; - // completed 비율을 줄이고 waiting/inProgress 위주로 생성 - const statusOptions: Array<'waiting' | 'inProgress' | 'completed'> = ['waiting', 'waiting', 'inProgress', 'inProgress', 'inProgress', 'completed']; - orders.push({ - id: `work-${i + 1}`, - orderNo: `KD-WO-${process === 'bending' ? 'FLD-' : ''}25${String(12).padStart(2, '0')}${String(i + 1).padStart(2, '0')}-${String((i % 3) + 1).padStart(2, '0')}`, - productName: productNames[i % productNames.length], - process, - client: clients[i % clients.length], - projectName: projects[i % projects.length], - assignees: [assigneePool[i % assigneePool.length]], - quantity: (i % 8) + 3, // 고정값 (3~10) - dueDate: `2025-${String((i % 3) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`, - priority: (i % 5) + 1, - status: statusOptions[i % statusOptions.length], - isUrgent: false, - isDelayed: false, - createdAt: '2025-12-20T09:00:00.000Z', // 고정값 - }); - } - - // 작업자 화면용 추가 데이터 (대기/작업중 상태만) - const additionalWaitingOrders = [ - { orderNo: 'KD-WO-251201-01', process: 'screen' as ProcessType, client: '삼성물산(주)', project: '강남 타워 신축현장 (B동)', product: '스크린 서터 (표준형) - 추가', quantity: 3, priority: 1 }, - { orderNo: 'KD-WO-251202-02', process: 'slat' as ProcessType, client: '현대건설(주)', project: '해운대 타워', product: '철재 슬랫 서터', quantity: 5, priority: 2 }, - { orderNo: 'KD-WO-251203-03', process: 'screen' as ProcessType, client: '대림건설(주)', project: '대치 레이크파크', product: '스크린 서터 (대형)', quantity: 2, priority: 3 }, - { orderNo: 'KD-WO-FLD-251204-01', process: 'bending' as ProcessType, client: '두산건설(주)', project: '위례 청라 센트럴파크', product: '방연셔터 절곡 부품', quantity: 8, priority: 1 }, - { orderNo: 'KD-WO-251205-04', process: 'screen' as ProcessType, client: '(주)서울인테리어', project: '판교 물류센터', product: '스크린 서터 (표준형) - 추가', quantity: 4, priority: 2 }, - ]; - - additionalWaitingOrders.forEach((item, i) => { - orders.push({ - id: `additional-waiting-${i + 1}`, - orderNo: item.orderNo, - productName: item.product, - process: item.process, - client: item.client, - projectName: item.project, - assignees: [assigneePool[i % assigneePool.length]], - quantity: item.quantity, - dueDate: '2025-01-01', - priority: item.priority, - status: 'waiting', - isUrgent: i === 0 || i === 3, // 1, 4번째 긴급 - isDelayed: false, - instruction: i === 0 ? '추가분 주문, 기존 납품분과 동일 사양 유지' : undefined, - createdAt: '2025-12-20T09:00:00.000Z', - }); - }); - - return orders; -}; - -// Mock 작업자 현황 데이터 -export const generateMockWorkerStatus = (): WorkerStatus[] => { - return [ - { id: 'w1', name: '김스크린', inProgress: 4, completed: 4, assigned: 9 }, - { id: 'w2', name: '박스크린', inProgress: 4, completed: 4, assigned: 5 }, - { id: 'w3', name: '김슬랫', inProgress: 0, completed: 3, assigned: 5 }, - { id: 'w4', name: '박슬랫', inProgress: 0, completed: 2, assigned: 2 }, - { id: 'w5', name: '이스크린', inProgress: 1, completed: 1, assigned: 2 }, - { id: 'w6', name: '최절곡', inProgress: 0, completed: 2, assigned: 3 }, - { id: 'w7', name: '이절곡', inProgress: 1, completed: 0, assigned: 1 }, - { id: 'w8', name: '최스크린', inProgress: 0, completed: 1, assigned: 2 }, - ]; -}; diff --git a/src/components/production/WorkOrders/mockData.ts b/src/components/production/WorkOrders/mockData.ts deleted file mode 100644 index ead4dcfd..00000000 --- a/src/components/production/WorkOrders/mockData.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * 작업지시 관리 목업 데이터 - */ - -import type { - WorkOrder, - SalesOrder, - WorkOrderStats, - BendingDetail, -} from './types'; - -// 전개도 상세 목업 (절곡용) -const bendingDetailsSample: BendingDetail[] = [ - { - id: 'bd-1', - code: 'SD30', - name: '엘바', - material: 'E.G.I 1.6T', - quantity: 4, - developWidth: '500mm', - length: '3000mm', - weight: '0.9kg', - note: '-', - developDimension: '75', - }, - { - id: 'bd-2', - code: 'SD31', - name: '하장바', - material: 'E.G.I 1.6T', - quantity: 2, - developWidth: '500mm', - length: '3000mm', - weight: '1.158kg', - note: '-', - developDimension: '67→126→165→178→193', - }, - { - id: 'bd-3', - code: 'SD32', - name: '짜부가스켓', - material: 'E.G.I 0.8T', - quantity: 4, - developWidth: '500mm', - length: '3000mm', - weight: '0.576kg', - note: '80*4,50*8', - developDimension: '48', - }, - { - id: 'bd-4', - code: 'SD33', - name: '50평철', - material: 'E.G.I 1.2T', - quantity: 2, - developWidth: '500mm', - length: '3000mm', - weight: '0.3kg', - note: '-', - developDimension: '50', - }, - { - id: 'bd-5', - code: 'SD36', - name: '밑면 점검구', - material: 'E.G.I 1.6T', - quantity: 2, - developWidth: '500mm', - length: '2438mm', - weight: '0.98kg', - note: '500*380', - developDimension: '90→240→310', - }, - { - id: 'bd-6', - code: 'SD37', - name: '후면코너부', - material: 'E.G.I 1.2T', - quantity: 4, - developWidth: '500mm', - length: '1219mm', - weight: '0.45kg', - note: '-', - developDimension: '35→85→120', - }, -]; - -// 작업지시 목업 데이터 -export const mockWorkOrders: WorkOrder[] = [ - { - id: 'wo-1', - workOrderNo: 'KD-WO-251217-12', - lotNo: 'KD-TS-251217-10', - processType: 'screen', - status: 'shipped', - client: '두산건설(주)', - projectName: '위브 청라 센트럴파크', - dueDate: '2025-12-30', - assignee: '최스크린', - orderDate: '2025-12-17', - shipmentDate: '2025-12-28', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [ - { id: 'item-1', no: 1, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/I-01', specification: '3500×2500', quantity: 1 }, - { id: 'item-2', no: 2, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/I-02', specification: '3500×2500', quantity: 1 }, - ], - }, - { - id: 'wo-2', - workOrderNo: 'KD-WO-251217-11', - lotNo: 'KD-TS-251217-09', - processType: 'screen', - status: 'in_progress', - client: '대영건설(주)', - projectName: '대시앙 동탄 레이크파크', - dueDate: '2026-02-08', - assignee: '김스크린', - orderDate: '2025-12-17', - shipmentDate: '2026-02-01', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 3, - items: [ - { id: 'item-3', no: 1, status: 'waiting', productName: '스크린 사타 (표준형)', floorCode: '1층/H-01', specification: '4000×3000', quantity: 1 }, - { id: 'item-4', no: 2, status: 'waiting', productName: '스크린 사타 (표준형)', floorCode: '2층/H-02', specification: '4000×3000', quantity: 1 }, - ], - issues: [ - { - id: 'issue-1', - status: 'processing', - type: '불량품발생', - description: '앤드락 접착불량 - 전체 재작업 필요', - createdAt: '2025-12-20', - }, - ], - }, - { - id: 'wo-3', - workOrderNo: 'KD-WO-251217-10', - lotNo: 'KD-TS-251217-08', - processType: 'screen', - status: 'waiting', - client: '포레나', - projectName: '포레나 수지 더 퍼스트', - dueDate: '2026-02-13', - assignee: '-', - orderDate: '2025-12-17', - shipmentDate: '2026-02-05', - isAssigned: false, - isStarted: false, - priority: 7, - currentStep: 0, - items: [ - { id: 'item-5', no: 1, status: 'waiting', productName: '스크린 셔터 (표준형)', floorCode: '1층/A-01', specification: '3000×2500', quantity: 1 }, - ], - }, - { - id: 'wo-4', - workOrderNo: 'KD-WO-251217-09', - lotNo: 'KD-TS-251217-07', - processType: 'slat', - status: 'in_progress', - client: '호반건설(주)', - projectName: '써밋 광교 리버파크', - dueDate: '2026-01-30', - assignee: '이슬랫', - orderDate: '2025-12-17', - shipmentDate: '2026-01-20', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 1, - items: [ - { id: 'item-6', no: 1, status: 'waiting', productName: '철재 슬랫 셔터', floorCode: '3층/F-05', specification: '3500×2500', quantity: 1 }, - ], - }, - { - id: 'wo-5', - workOrderNo: 'KD-WO-251217-08', - lotNo: 'KD-TS-251217-07', - processType: 'screen', - status: 'completed', - client: '삼성물산(주)', - projectName: '써밋 광교 리버파크', - dueDate: '2026-01-18', - assignee: '박스크린', - orderDate: '2025-12-17', - shipmentDate: '2026-01-10', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [ - { id: 'item-7', no: 1, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/A-01', specification: '3500×2500', quantity: 1 }, - ], - }, - { - id: 'wo-6', - workOrderNo: 'KD-WO-FLD-251216-01', - lotNo: 'KD-TS-251216-06', - processType: 'bending', - status: 'completed', - client: '삼성물산(주)', - projectName: '[E2E테스트] 절곡 전용 현장', - dueDate: '2025-12-28', - assignee: '최절곡', - orderDate: '2025-12-16', - shipmentDate: '2025-12-24', - isAssigned: true, - isStarted: true, - priority: 3, - currentStep: 4, - items: [ - { id: 'item-8', no: 1, status: 'waiting', productName: '방화셔터 절곡 부품 SET (E2E)', floorCode: '테스트층/E2E-G-01', specification: '3000×4000', quantity: 1 }, - ], - bendingDetails: bendingDetailsSample, - issues: [ - { - id: 'issue-2', - status: 'processing', - type: '불량품발생', - description: '중간검사 불합격 - 절곡 각도 불량 1EA (90° 기준 ±2° 초과)', - createdAt: '2025-12-22', - }, - ], - }, - { - id: 'wo-7', - workOrderNo: 'KD-WO-251217-07', - lotNo: 'KD-TS-251217-07', - processType: 'screen', - status: 'completed', - client: '삼성물산(주)', - projectName: '써밋 광교 리버파크', - dueDate: '2026-01-08', - assignee: '김스크린', - orderDate: '2025-12-17', - shipmentDate: '2025-12-30', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-8', - workOrderNo: 'KD-WO-251217-06', - lotNo: 'KD-TS-251217-05', - processType: 'screen', - status: 'completed', - client: '대산', - projectName: '대산 송도 마린베이', - dueDate: '2026-01-28', - assignee: '최스크린', - orderDate: '2025-12-17', - shipmentDate: '2026-01-20', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-9', - workOrderNo: 'KD-WO-251217-05', - lotNo: 'KD-TS-251217-04', - processType: 'slat', - status: 'completed', - client: '자이', - projectName: '자이 위례 더 퍼스트', - dueDate: '2026-01-28', - assignee: '이슬랫', - orderDate: '2025-12-17', - shipmentDate: '2026-01-20', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-10', - workOrderNo: 'KD-WO-251217-04', - lotNo: 'KD-TS-251217-03', - processType: 'screen', - status: 'completed', - client: '푸르지오', - projectName: '푸르지오 일산 센트럴파크', - dueDate: '2026-01-23', - assignee: '박스크린', - orderDate: '2025-12-17', - shipmentDate: '2026-01-15', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-11', - workOrderNo: 'KD-WO-251217-03', - lotNo: 'KD-TS-251217-02', - processType: 'bending', - status: 'completed', - client: '힐스테이트', - projectName: '힐스테이트 판교 더 퍼스트', - dueDate: '2026-01-18', - assignee: '최절곡', - orderDate: '2025-12-17', - shipmentDate: '2026-01-10', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 4, - items: [], - }, - { - id: 'wo-12', - workOrderNo: 'KD-WO-251217-02', - lotNo: 'KD-TS-251217-82', - processType: 'screen', - status: 'completed', - client: '힐스테이트', - projectName: '힐스테이트 판교 더 머스트', - dueDate: '2026-01-08', - assignee: '김스크린', - orderDate: '2025-12-17', - shipmentDate: '2025-12-30', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-13', - workOrderNo: 'KD-WO-251217-01', - lotNo: 'KD-TS-251217-81', - processType: 'screen', - status: 'completed', - client: '레미안', - projectName: '레미안 강남 프레스티지', - dueDate: '2026-01-13', - assignee: '최스크린', - orderDate: '2025-12-17', - shipmentDate: '2026-01-05', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-14', - workOrderNo: 'KD-WO-FLD-251215-01', - lotNo: 'KD-TS-251215-01', - processType: 'bending', - status: 'completed', - client: '삼성물산(주)', - projectName: '송도 아파트 B동', - dueDate: '2025-12-30', - assignee: '최절곡', - orderDate: '2025-12-15', - shipmentDate: '2025-12-25', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 4, - items: [], - }, - { - id: 'wo-15', - workOrderNo: 'KD-WO-FLD-251212-01', - lotNo: 'KD-TS-251212-81', - processType: 'bending', - status: 'completed', - client: '삼성물산(주)', - projectName: '판교 롯데센터', - dueDate: '2025-12-26', - assignee: '최절곡', - orderDate: '2025-12-13', - shipmentDate: '2025-12-22', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 4, - items: [], - }, - { - id: 'wo-16', - workOrderNo: 'KD-WO-FLD-251210-01', - lotNo: 'KD-TS-251210-81', - processType: 'screen', - status: 'completed', - client: '삼성물산(주)', - projectName: '강남 타워 신축현장', - dueDate: '2025-12-25', - assignee: '김스크린', - orderDate: '2025-12-10', - shipmentDate: '2025-12-20', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, - { - id: 'wo-17', - workOrderNo: 'KD-WO-FLD-251208-01', - lotNo: 'KD-TS-251288-01', - processType: 'screen', - status: 'completed', - client: '삼성물산(주)', - projectName: '배곧더 타워', - dueDate: '2025-12-22', - assignee: '박스크린', - orderDate: '2025-12-08', - shipmentDate: '2025-12-18', - isAssigned: true, - isStarted: true, - priority: 5, - currentStep: 5, - items: [], - }, -]; - -// 수주 목록 목업 데이터 -export const mockSalesOrders: SalesOrder[] = [ - { - id: 'so-1', - orderNo: 'KD-TS-251201-01', - status: '생산지시완료', - client: '삼성물산(주)', - projectName: '삼성물산 레미안 강남 1차', - dueDate: '2025-12-20', - itemCount: 2, - splitCount: 1, - }, - { - id: 'so-2', - orderNo: 'KD-TS-251205-01-A', - status: '생산지시완료', - client: '삼성물산(주)', - projectName: '삼성물산 레미안 강남 1차', - dueDate: '2025-12-28', - itemCount: 1, - splitCount: 1, - }, - { - id: 'so-3', - orderNo: 'KD-TS-251206-01', - status: '생산지시완료', - client: '(주)서울인테리어', - projectName: '강남 오피스타워 인테리어', - dueDate: '2025-12-29', - itemCount: 2, - splitCount: 2, - }, - { - id: 'so-4', - orderNo: 'KD-TS-251207-01', - status: '생산지시완료', - client: '삼성물산(주)', - projectName: '삼성물산 레미안 강남 2차', - dueDate: '2025-12-28', - itemCount: 1, - splitCount: 1, - }, -]; - -// 통계 계산 -export function calculateStats(orders: WorkOrder[]): WorkOrderStats { - return { - total: orders.length, - unassigned: orders.filter((o) => o.status === 'unassigned').length, - pending: orders.filter((o) => o.status === 'pending').length, - waiting: orders.filter((o) => o.status === 'waiting').length, - inProgress: orders.filter((o) => o.status === 'in_progress').length, - completed: orders.filter((o) => o.status === 'completed' || o.status === 'shipped').length, - }; -} - -// 기본 통계 -export const mockStats: WorkOrderStats = calculateStats(mockWorkOrders); \ No newline at end of file diff --git a/src/components/production/WorkResults/mockData.ts b/src/components/production/WorkResults/mockData.ts deleted file mode 100644 index 6ed2d30d..00000000 --- a/src/components/production/WorkResults/mockData.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 작업실적 조회 Mock 데이터 - */ - -import type { WorkResult, WorkResultStats } from './types'; - -// Mock 작업실적 데이터 -export const mockWorkResults: WorkResult[] = [ - { - id: 'wr-1', - lotNo: 'KD-TS-250212-01-01', - workDate: '2025-02-12', - workOrderNo: 'KD-PL-250122-01', - processType: 'screen', - productName: '스크린 셔터 (프리미엄)', - specification: '8000x2800', - productionQty: 1, - goodQty: 0, - defectQty: 1, - defectRate: 100.0, - inspection: true, - packaging: false, - worker: '이성산', - }, - { - id: 'wr-2', - lotNo: 'KD-TS-250210-01-02', - workDate: '2025-02-10', - workOrderNo: 'KD-PL-250120-01', - processType: 'screen', - productName: '스크린 셔터 (표준형)', - specification: '6500x2400', - productionQty: 1, - goodQty: 1, - defectQty: 0, - defectRate: 0.0, - inspection: true, - packaging: true, - worker: '김성산', - }, - { - id: 'wr-3', - lotNo: 'KD-TS-250210-01-01', - workDate: '2025-02-10', - workOrderNo: 'KD-PL-250120-01', - processType: 'screen', - productName: '스크린 셔터 (표준형)', - specification: '7660x2550', - productionQty: 1, - goodQty: 1, - defectQty: 0, - defectRate: 0.0, - inspection: true, - packaging: true, - worker: '김성산', - }, - { - id: 'wr-4', - lotNo: 'KD-TS-250208-01-01', - workDate: '2025-02-08', - workOrderNo: 'KD-PL-250118-01', - processType: 'slat', - productName: '철재 슬랫 셔터', - specification: '5000x3000', - productionQty: 2, - goodQty: 2, - defectQty: 0, - defectRate: 0.0, - inspection: true, - packaging: true, - worker: '박철호', - }, - { - id: 'wr-5', - lotNo: 'KD-TS-250205-01-01', - workDate: '2025-02-05', - workOrderNo: 'KD-PL-250115-01', - processType: 'bending', - productName: '방화셔터 절곡 부품 SET', - specification: '3000x4000', - productionQty: 3, - goodQty: 2, - defectQty: 1, - defectRate: 33.3, - inspection: true, - packaging: true, - worker: '최절곡', - }, - { - id: 'wr-6', - lotNo: 'KD-TS-250203-01-01', - workDate: '2025-02-03', - workOrderNo: 'KD-PL-250113-01', - processType: 'screen', - productName: '스크린 셔터 (프리미엄)', - specification: '9000x3200', - productionQty: 1, - goodQty: 1, - defectQty: 0, - defectRate: 0.0, - inspection: true, - packaging: true, - worker: '이성산', - }, - { - id: 'wr-7', - lotNo: 'KD-TS-250201-01-01', - workDate: '2025-02-01', - workOrderNo: 'KD-PL-250111-01', - processType: 'slat', - productName: '알루미늄 슬랫 셔터', - specification: '4500x2800', - productionQty: 2, - goodQty: 1, - defectQty: 1, - defectRate: 50.0, - inspection: true, - packaging: true, - worker: '박철호', - }, - { - id: 'wr-8', - lotNo: 'KD-TS-250130-01-01', - workDate: '2025-01-30', - workOrderNo: 'KD-PL-250109-01', - processType: 'screen', - productName: '스크린 셔터 (표준형)', - specification: '7000x2600', - productionQty: 1, - goodQty: 1, - defectQty: 0, - defectRate: 0.0, - inspection: true, - packaging: true, - worker: '김성산', - }, -]; - -// 통계 계산 -export function calculateWorkResultStats(results: WorkResult[]): WorkResultStats { - const totalProduction = results.reduce((sum, r) => sum + r.productionQty, 0); - const totalGood = results.reduce((sum, r) => sum + r.goodQty, 0); - const totalDefect = results.reduce((sum, r) => sum + r.defectQty, 0); - const defectRate = totalProduction > 0 ? (totalDefect / totalProduction) * 100 : 0; - - return { - totalProduction, - totalGood, - totalDefect, - defectRate: Math.round(defectRate * 10) / 10, - }; -} - -// 기본 통계 -export const mockStats: WorkResultStats = calculateWorkResultStats(mockWorkResults); \ No newline at end of file diff --git a/src/components/reports/mockData.ts b/src/components/reports/mockData.ts deleted file mode 100644 index 9738d2a6..00000000 --- a/src/components/reports/mockData.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { - ComprehensiveAnalysisData, -} from './types'; - -// 종합 경영 분석 목데이터 -export const comprehensiveAnalysisMockData: ComprehensiveAnalysisData = { - // 오늘의 이슈 - todayIssue: { - filterOptions: ['전체필터', '문서요청', '계약요청', '결재요청'], - items: [ - { - id: 'issue-1', - category: '문서요청', - description: '매입 XX일 후 완료처리/대물처리 처리', - requiresApproval: true, - time: '09:30', - }, - { - id: 'issue-2', - category: '문서요청', - description: '매입 XX 처리', - requiresApproval: false, - time: '10:15', - }, - { - id: 'issue-3', - category: '문서요청', - description: '문서요청 승인 / 미완료처리 중', - requiresApproval: false, - time: '11:00', - }, - { - id: 'issue-4', - category: '계약요청', - description: '문서의 후 XXXXXXXX처리', - requiresApproval: true, - time: '14:30', - }, - ], - }, - - // 당월 예상 지출 내역 - monthlyExpense: { - cards: [ - { id: 'expense-1', label: '노동 외상 매출금 비율', amount: 1123000 }, - { id: 'expense-2', label: '노동 외상 매출금 비율', amount: 3123000 }, - { id: 'expense-3', label: '노동 외상 매출금 비율', amount: 1123000 }, - { id: 'expense-4', label: '노동 외상 매출금 비율', amount: 3123000 }, - ], - checkPoints: [ - { - id: 'expense-cp-1', - type: 'warning', - message: '이번 달 예상 지출은', - highlight: 'XXX,XX원이 될 것으로 예상되어 이번 달의 결산이 약간 미흡합니다.', - }, - { - id: 'expense-cp-2', - type: 'success', - message: '이번 달 예상 지출은 0000000원에 비해 발생한 잔액이 부족합니다.', - }, - { - id: 'expense-cp-3', - type: 'info', - message: '이번 달 예상 지출은 xx xxxxx원으로 상환을 낙관할 수 있습니다.', - }, - ], - }, - - // 카드/가지급금 관리 - cardManagement: { - cards: [ - { id: 'card-1', label: '노동 외상 매출금 비율', amount: 1123000 }, - { id: 'card-2', label: '노동 외상 매출금 비율', amount: 3123000 }, - { id: 'card-3', label: '노동 외상 매출금 비율', amount: 1123000 }, - { id: 'card-4', label: '노동 외상 매출금 비율', amount: 1123000 }, - ], - checkPoints: [ - { - id: 'card-cp-1', - type: 'warning', - message: '김철민 외 카드 미결제/미수금/미환급/이월이 발생 하여', - highlight: '대조가 필요합니다.', - }, - { - id: 'card-cp-2', - type: 'info', - message: '회사 가지급금 1,000만원 → 3% 초과/부족 발 →', - highlight: '개선방안: 매출 신규영업과 대상', - }, - { - id: 'card-cp-3', - type: 'success', - message: '현재 미지급금 감소추세 → 목표대비 양호 →', - highlight: '1개월 순영업성과', - }, - ], - }, - - // 접대비 현황 - entertainment: { - cards: [ - { id: 'ent-1', label: '노동 외상 매출금 비율', amount: 1123000 }, - { id: 'ent-2', label: '노동 외상 매출금 비율', amount: 1123000 }, - ], - checkPoints: [ - { - id: 'ent-cp-1', - type: 'warning', - message: '김철민 씨 사용액 1,000원에 중복지출로', - highlight: '비용 반영 준비가 필요합니다.', - }, - { - id: 'ent-cp-2', - type: 'info', - message: '관리비 이번 달 2% X X원이 비용처리로 이재현 증분의 확인이 필요합니다.', - }, - { - id: 'ent-cp-3', - type: 'error', - message: '김철민 고객접대비가 목표대비', - highlight: '5%초과로 허용 초과처리 되었습니다.', - }, - { - id: 'ent-cp-4', - type: 'warning', - message: '이채호 외 3명에 대한 XXXXXXXXX 비용처리 불가입니다.', - }, - ], - }, - - // 복리후생비 현황 - welfare: { - cards: [ - { id: 'wf-1', label: '노동 복리후생비 대비 지출', amount: 3123000 }, - { id: 'wf-2', label: '노동 복리후생비 수당 내역', amount: 1123000 }, - { id: 'wf-3', label: '방송일 복리후생비 지출 내역', amount: 30123000 }, - { id: 'wf-4', label: '방송일 복리후생비 수당 내역', amount: 3123000 }, - ], - checkPoints: [ - { - id: 'wf-cp-1', - type: 'warning', - message: '직원 월 복리후생비 비율이 정상 범위(5~20%)에서 벗어난 것이 발견됨', - }, - { - id: 'wf-cp-2', - type: 'info', - message: '복리후생비 월 직원혜택 비 지출에 관심(00백)원을 초과했습니다. 초과분은 근로소득 과세대상입니다.', - }, - ], - }, - - // 미수금 현황 - receivable: { - cards: [ - { - id: 'rcv-1', - label: '노동 외상 매출금 비율', - amount: 30123000, - subAmount: 8000000, - subLabel: '미결', - previousAmount: 5000000, - previousLabel: '비용', - }, - { - id: 'rcv-2', - label: '발행된 매출금 비율', - amount: 30123000, - subAmount: 8000000, - subLabel: '미결', - previousAmount: 5000000, - previousLabel: '비용', - }, - { - id: 'rcv-3', - label: '금일 매출원가 (외상제외)', - amount: 3123000, - subAmount: 800000, - subLabel: '미결', - previousAmount: 100000, - previousLabel: '비용', - }, - { - id: 'rcv-4', - label: '금일 수금원가 (외상제외)', - amount: 3123000, - subAmount: 500000, - subLabel: '미결', - previousAmount: 100000, - previousLabel: '비용', - }, - ], - checkPoints: [ - { - id: 'rcv-cp-1', - type: 'warning', - message: '이부서 가장 큰의 미수금 3/21,500/20원 정도, 월수 초과가 발생합니다.', - }, - { - id: 'rcv-cp-2', - type: 'error', - message: '[주식회사]의 미수금 4,300만원으로 한계 미수금 초과입니다.', - highlight: '거래 중단이 필요합니다.', - }, - ], - hasDetailButton: true, - detailButtonLabel: '거래처별 미수금 현황', - detailButtonPath: '/accounting/receivables-status', - }, - - // 채권추심 현황 - debtCollection: { - cards: [ - { id: 'debt-1', label: '노동 외상 매출금 비율', amount: 30123000 }, - { id: 'debt-2', label: '노동 외상 매출금 비율', amount: 30123000 }, - { id: 'debt-3', label: '노동 외상 매출금 비율', amount: 3123000 }, - { id: 'debt-4', label: '노동 외상 매출금 비율', amount: 3123000 }, - ], - checkPoints: [ - { - id: 'debt-cp-1', - type: 'info', - message: '[주식회사]의 건 직급상한 건수 첫째, 팔로 결제월이 약 3건 소요 예정입니다.', - }, - { - id: 'debt-cp-2', - type: 'warning', - message: '[주식회사]의 건 수 총 3건 중에, 현재 1건 보류상태입니다.', - }, - ], - }, -}; - -// 금액 포맷 함수 -export const formatAmount = (amount: number): string => { - return new Intl.NumberFormat('ko-KR').format(amount) + '원'; -}; \ No newline at end of file diff --git a/src/contexts/FCMProvider.tsx b/src/contexts/FCMProvider.tsx new file mode 100644 index 00000000..44ff03de --- /dev/null +++ b/src/contexts/FCMProvider.tsx @@ -0,0 +1,81 @@ +'use client'; + +/** + * FCM Provider + * + * Capacitor 네이티브 앱에서 FCM 푸시 알림을 초기화합니다. + * RootProvider 또는 (protected)/layout.tsx에서 사용합니다. + * + * @example + * ```tsx + * // (protected)/layout.tsx + * import { FCMProvider } from '@/contexts/FCMProvider'; + * + * export default function ProtectedLayout({ children }) { + * return ( + * + * + * {children} + * + * + * ); + * } + * ``` + */ + +import { ReactNode, createContext, useContext } from 'react'; +import { useFCM } from '@/hooks/useFCM'; + +// ===== Context 타입 ===== + +interface FCMContextType { + cleanup: () => Promise; +} + +const FCMContext = createContext(undefined); + +// ===== Provider 컴포넌트 ===== + +export function FCMProvider({ children }: { children: ReactNode }) { + // FCM 훅 실행 (초기화) + const { cleanup } = useFCM(); + + return ( + + {children} + + ); +} + +// ===== Custom Hook ===== + +/** + * FCM Context 사용 훅 + * + * @example + * ```tsx + * function LogoutButton() { + * const { cleanup } = useFCMContext(); + * + * const handleLogout = async () => { + * await cleanup(); // FCM 토큰 해제 + * // ... 로그아웃 처리 + * }; + * } + * ``` + */ +export function useFCMContext() { + const context = useContext(FCMContext); + if (context === undefined) { + throw new Error('useFCMContext must be used within a FCMProvider'); + } + return context; +} + +/** + * FCM Context 안전하게 사용 (Provider 외부에서도 사용 가능) + * undefined 반환 시 FCM이 비활성화됨 + */ +export function useFCMContextSafe() { + return useContext(FCMContext); +} \ No newline at end of file diff --git a/src/hooks/useFCM.ts b/src/hooks/useFCM.ts new file mode 100644 index 00000000..228e568d --- /dev/null +++ b/src/hooks/useFCM.ts @@ -0,0 +1,137 @@ +'use client'; + +/** + * FCM 푸시 알림 훅 + * + * Capacitor 네이티브 앱에서 FCM 푸시 알림을 처리합니다. + * - 로그인 상태에서 자동으로 FCM 초기화 + * - 포그라운드 알림을 sonner 토스트로 표시 + * - 로그아웃 시 FCM 토큰 해제 + * + * @example + * ```tsx + * // FCMProvider에서 사용 + * function FCMProvider({ children }) { + * useFCM(); + * return <>{children}; + * } + * ``` + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { toast } from 'sonner'; +import { + initializeFCM, + unregisterFCMToken, + isCapacitorNative, + getToastTypeByNotificationType, + type FCMNotification, + type ToastType, +} from '@/lib/capacitor/fcm'; +import { hasAuthToken } from '@/lib/api/auth-headers'; + +export function useFCM() { + const initialized = useRef(false); + + /** + * 포그라운드 알림 핸들러 (sonner 토스트) + */ + const handleForegroundNotification = useCallback((notification: FCMNotification) => { + const { title, body, data } = notification; + const type = data?.type; + const url = data?.url; + + // 타입별 토스트 스타일 결정 + const toastType: ToastType = getToastTypeByNotificationType(type); + + // 토스트 옵션 + const toastOptions = { + description: body, + duration: 5000, + action: url + ? { + label: '보기', + onClick: () => { + window.location.href = url; + }, + } + : undefined, + }; + + // 타입별 토스트 표시 + const toastTitle = title || '알림'; + + switch (toastType) { + case 'error': + toast.error(toastTitle, toastOptions); + break; + case 'warning': + toast.warning(toastTitle, toastOptions); + break; + case 'success': + toast.success(toastTitle, toastOptions); + break; + default: + toast.info(toastTitle, toastOptions); + } + }, []); + + /** + * FCM 초기화 + */ + useEffect(() => { + // 네이티브 환경이 아니면 무시 + if (!isCapacitorNative()) { + console.log('[useFCM] Not in native environment, skipping'); + return; + } + + // 이미 초기화됐으면 무시 + if (initialized.current) { + return; + } + + // 로그인 상태가 아니면 무시 + if (!hasAuthToken()) { + console.log('[useFCM] No auth token, skipping FCM initialization'); + return; + } + + // FCM 초기화 + initialized.current = true; + console.log('[useFCM] Initializing FCM...'); + + initializeFCM(handleForegroundNotification).then((success) => { + if (success) { + console.log('[useFCM] FCM initialized successfully'); + } else { + console.log('[useFCM] FCM initialization failed or skipped'); + initialized.current = false; + } + }); + }, [handleForegroundNotification]); + + /** + * FCM 토큰 해제 (로그아웃 시 호출) + */ + const cleanup = useCallback(async () => { + console.log('[useFCM] Cleaning up FCM...'); + await unregisterFCMToken(); + initialized.current = false; + }, []); + + return { cleanup }; +} + +/** + * FCM cleanup 함수만 반환하는 훅 + * (로그아웃 로직에서 사용) + */ +export function useFCMCleanup() { + const cleanup = useCallback(async () => { + if (!isCapacitorNative()) return; + await unregisterFCMToken(); + }, []); + + return { cleanup }; +} diff --git a/src/lib/api/positions.ts b/src/lib/api/positions.ts new file mode 100644 index 00000000..77619c80 --- /dev/null +++ b/src/lib/api/positions.ts @@ -0,0 +1,296 @@ +/** + * 직급/직책 통합 API 클라이언트 + * + * Laravel 백엔드 positions 테이블과 통신 + * type: 'rank' = 직급, 'title' = 직책 + */ + +// ===== 타입 정의 ===== + +export type PositionType = 'rank' | 'title'; + +export interface Position { + id: number; + tenant_id: number; + type: PositionType; + name: string; + sort_order: number; + is_active: boolean; + created_at?: string; + updated_at?: string; +} + +export interface PositionCreateRequest { + type: PositionType; + name: string; + sort_order?: number; + is_active?: boolean; +} + +export interface PositionUpdateRequest { + name?: string; + sort_order?: number; + is_active?: boolean; +} + +export interface PositionReorderItem { + id: number; + sort_order: number; +} + +export interface PositionListParams { + type?: PositionType; + is_active?: boolean; + q?: string; + per_page?: number; + page?: number; +} + +interface ApiResponse { + success: boolean; + message: string; + data: T; +} + +// ===== 환경 변수 ===== + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.sam.kr'; + +// ===== 유틸리티 함수 ===== + +/** + * 인증 토큰 가져오기 + */ +function getAuthToken(): string | null { + if (typeof window !== 'undefined') { + return localStorage.getItem('auth_token'); + } + return null; +} + +/** + * API Key 가져오기 + */ +function getApiKey(): string { + return process.env.NEXT_PUBLIC_API_KEY || ''; +} + +/** + * Fetch 옵션 생성 + */ +function createFetchOptions(options: RequestInit = {}): RequestInit { + const token = getAuthToken(); + const apiKey = getApiKey(); + + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + // API Key 추가 + if (apiKey) { + headers['X-API-KEY'] = apiKey; + } + + // Bearer 토큰 추가 + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // Merge existing headers + if (options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)) { + Object.assign(headers, options.headers); + } + + return { + ...options, + headers, + credentials: 'include', + }; +} + +/** + * API 에러 처리 + */ +async function handleApiResponse(response: Response): Promise { + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: 'API 요청 실패', + })); + + throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data; +} + +// ===== Position CRUD API ===== + +/** + * 직급/직책 목록 조회 + * + * @param params - 필터 파라미터 + * @example + * // 직급만 조회 + * const ranks = await fetchPositions({ type: 'rank' }); + * // 직책만 조회 + * const titles = await fetchPositions({ type: 'title' }); + */ +export async function fetchPositions( + params?: PositionListParams +): Promise { + const queryParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + queryParams.append(key, String(value)); + } + }); + } + + const url = `${API_URL}/v1/positions${queryParams.toString() ? `?${queryParams}` : ''}`; + + const response = await fetch(url, createFetchOptions()); + const result = await handleApiResponse>(response); + + return result.data; +} + +/** + * 직급/직책 단건 조회 + * + * @param id - Position ID + */ +export async function fetchPosition(id: number): Promise { + const response = await fetch( + `${API_URL}/v1/positions/${id}`, + createFetchOptions() + ); + + const result = await handleApiResponse>(response); + return result.data; +} + +/** + * 직급/직책 생성 + * + * @param data - 생성 데이터 + * @example + * const newRank = await createPosition({ + * type: 'rank', + * name: '차장', + * }); + */ +export async function createPosition( + data: PositionCreateRequest +): Promise { + const response = await fetch( + `${API_URL}/v1/positions`, + createFetchOptions({ + method: 'POST', + body: JSON.stringify(data), + }) + ); + + const result = await handleApiResponse>(response); + return result.data; +} + +/** + * 직급/직책 수정 + * + * @param id - Position ID + * @param data - 수정 데이터 + */ +export async function updatePosition( + id: number, + data: PositionUpdateRequest +): Promise { + const response = await fetch( + `${API_URL}/v1/positions/${id}`, + createFetchOptions({ + method: 'PUT', + body: JSON.stringify(data), + }) + ); + + const result = await handleApiResponse>(response); + return result.data; +} + +/** + * 직급/직책 삭제 + * + * @param id - Position ID + */ +export async function deletePosition(id: number): Promise { + const response = await fetch( + `${API_URL}/v1/positions/${id}`, + createFetchOptions({ + method: 'DELETE', + }) + ); + + await handleApiResponse>(response); +} + +/** + * 직급/직책 순서 일괄 변경 + * + * @param items - 정렬할 아이템 목록 + * @example + * await reorderPositions([ + * { id: 1, sort_order: 1 }, + * { id: 2, sort_order: 2 }, + * ]); + */ +export async function reorderPositions( + items: PositionReorderItem[] +): Promise<{ success: boolean; updated: number }> { + const response = await fetch( + `${API_URL}/v1/positions/reorder`, + createFetchOptions({ + method: 'PUT', + body: JSON.stringify({ items }), + }) + ); + + const result = await handleApiResponse>(response); + return result.data; +} + +// ===== 헬퍼 함수 ===== + +/** + * 직급 목록 조회 (헬퍼) + */ +export async function fetchRanks(params?: Omit): Promise { + return fetchPositions({ ...params, type: 'rank' }); +} + +/** + * 직책 목록 조회 (헬퍼) + */ +export async function fetchTitles(params?: Omit): Promise { + return fetchPositions({ ...params, type: 'title' }); +} + +/** + * 직급 생성 (헬퍼) + */ +export async function createRank( + data: Omit +): Promise { + return createPosition({ ...data, type: 'rank' }); +} + +/** + * 직책 생성 (헬퍼) + */ +export async function createTitle( + data: Omit +): Promise { + return createPosition({ ...data, type: 'title' }); +} \ No newline at end of file diff --git a/src/lib/capacitor/fcm.ts b/src/lib/capacitor/fcm.ts new file mode 100644 index 00000000..65ad6ef2 --- /dev/null +++ b/src/lib/capacitor/fcm.ts @@ -0,0 +1,380 @@ +/** + * FCM (Firebase Cloud Messaging) Push Notification Handler + * Capacitor 앱에서만 동작, 웹 브라우저에서는 무시됨 + * + * 포팅 원본: mng/public/js/fcm.js + * + * Payload data 스키마: + * - type: 알림 타입 (invoice_failed, order_completed 등) + * - url: 클릭 시 이동 URL + * - sound_key: 사운드 파일 키 (sounds/{sound_key}.wav) + * + * NOTE: Capacitor 모듈은 동적 import로 로드됨 (웹 빌드 에러 방지) + */ + +// 동적 로드된 모듈 캐시 (any 타입 - 웹 빌드 시 모듈 없음) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let Capacitor: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let PushNotifications: any = null; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let App: any = null; + +/** + * Capacitor 모듈 동적 로드 + */ +async function loadCapacitorModules(): Promise { + if (Capacitor && PushNotifications && App) return true; + + try { + const [coreModule, pushModule, appModule] = await Promise.all([ + import('@capacitor/core'), + import('@capacitor/push-notifications'), + import('@capacitor/app'), + ]); + Capacitor = coreModule.Capacitor; + PushNotifications = pushModule.PushNotifications; + App = appModule.App; + return true; + } catch { + console.log('[FCM] Capacitor modules not available (web environment)'); + return false; + } +} + +// ===== 설정 ===== + +const CONFIG = { + // Next.js 프록시 경로 (HttpOnly 쿠키 자동 포함) + proxyBasePath: '/api/proxy/v1', + fcmTokenKey: 'fcm_token', + soundBasePath: '/sounds/', + defaultSound: 'default', +}; + +// ===== 타입 정의 ===== + +export interface PushNotificationData { + type?: string; + url?: string; + sound_key?: string; + [key: string]: unknown; +} + +export interface FCMNotification { + title?: string; + body?: string; + data?: PushNotificationData; +} + +export type ForegroundNotificationHandler = (notification: FCMNotification) => void; + +// ===== 상태 ===== + +let isAppForeground = true; +let isInitialized = false; + +// ===== 유틸리티 함수 ===== + +/** + * Capacitor 네이티브 환경인지 확인 + * 동기 함수 - Capacitor가 로드되지 않은 경우 false 반환 + */ +export function isCapacitorNative(): boolean { + if (!Capacitor) return false; + const platform = Capacitor.getPlatform(); + return platform === 'ios' || platform === 'android'; +} + +/** + * 현재 플랫폼 반환 + */ +export function getDevicePlatform(): 'ios' | 'android' | 'web' { + if (!Capacitor) return 'web'; + const platform = Capacitor.getPlatform(); + if (platform === 'ios' || platform === 'android') return platform; + return 'web'; +} + +/** + * 디바이스 이름 반환 (User-Agent 기반) + */ +function getDeviceName(): string | null { + return navigator.userAgent?.substring(0, 100) || null; +} + +/** + * 앱 버전 반환 + */ +function getAppVersion(): string | null { + return process.env.NEXT_PUBLIC_APP_VERSION || null; +} + +// ===== FCM 핵심 함수 ===== + +/** + * FCM 초기화 (Capacitor 네이티브 환경에서만 동작) + * + * @param onForegroundNotification 포그라운드 알림 핸들러 (sonner toast 등) + * @returns 초기화 성공 여부 + */ +export async function initializeFCM( + onForegroundNotification?: ForegroundNotificationHandler +): Promise { + // Capacitor 모듈 동적 로드 + const modulesLoaded = await loadCapacitorModules(); + if (!modulesLoaded || !Capacitor || !PushNotifications || !App) { + console.log('[FCM] Not running in native app'); + return false; + } + + // 네이티브 환경 체크 + if (!isCapacitorNative()) { + console.log('[FCM] Not running in native app'); + return false; + } + + if (!Capacitor.isPluginAvailable('PushNotifications')) { + console.log('[FCM] PushNotifications plugin not available'); + return false; + } + + if (isInitialized) { + console.log('[FCM] Already initialized'); + return true; + } + + try { + // 앱 상태 리스너 (포그라운드/백그라운드) + if (Capacitor.isPluginAvailable('App')) { + App.addListener('appStateChange', ({ isActive }) => { + isAppForeground = isActive; + console.log('[FCM] App state:', isActive ? 'foreground' : 'background'); + }); + } + + // 기존 리스너 제거 + await PushNotifications.removeAllListeners(); + + // 리스너 등록 (register() 호출 전에 등록해야 함) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PushNotifications.addListener('registration', async (token: any) => { + console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...'); + await handleTokenRegistration(token.value); + }); + + PushNotifications.addListener('registrationError', (err) => { + console.error('[FCM] Registration error:', err); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PushNotifications.addListener('pushNotificationReceived', (notification: any) => { + console.log('[FCM] Push received (foreground):', notification); + + const fcmNotification: FCMNotification = { + title: notification.title, + body: notification.body, + data: notification.data as PushNotificationData, + }; + + // 포그라운드 알림 콜백 호출 + if (onForegroundNotification) { + onForegroundNotification(fcmNotification); + } + + // 사운드 재생 + handleForegroundSound(notification.data?.sound_key); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PushNotifications.addListener('pushNotificationActionPerformed', (action: any) => { + console.log('[FCM] Push action performed:', action); + const url = action.notification?.data?.url; + if (url && typeof url === 'string') { + // URL 이동 (router.push 대신 window.location.href 사용 - 확실한 이동) + window.location.href = url; + } + }); + + // 권한 요청 + const perm = await PushNotifications.requestPermissions(); + console.log('[FCM] Push permission:', perm.receive); + + if (perm.receive !== 'granted') { + console.log('[FCM] Push permission not granted'); + return false; + } + + // 토큰 발급 요청 (registration 이벤트 트리거) + await PushNotifications.register(); + + isInitialized = true; + console.log('[FCM] Initialization completed'); + return true; + + } catch (error) { + console.error('[FCM] Initialization error:', error); + return false; + } +} + +/** + * 토큰 등록 처리 + */ +async function handleTokenRegistration(newToken: string): Promise { + if (typeof window === 'undefined') return; + + const oldToken = sessionStorage.getItem(CONFIG.fcmTokenKey); + + if (oldToken === newToken) { + console.log('[FCM] Token unchanged, skip'); + return; + } + + const success = await registerTokenToServer(newToken); + + if (success) { + sessionStorage.setItem(CONFIG.fcmTokenKey, newToken); + console.log('[FCM] Token saved to sessionStorage'); + } +} + +/** + * 서버에 토큰 등록 (Next.js 프록시 사용) + */ +async function registerTokenToServer(token: string): Promise { + try { + // Next.js 프록시 경로 사용 (HttpOnly 쿠키 자동 포함) + const response = await fetch(`${CONFIG.proxyBasePath}/push/register-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + credentials: 'include', // 쿠키 포함 + body: JSON.stringify({ + token, + platform: getDevicePlatform(), + device_name: getDeviceName(), + app_version: getAppVersion(), + }), + }); + + if (response.ok) { + console.log('[FCM] Token registered successfully'); + return true; + } + + console.error('[FCM] Token registration failed:', response.status); + return false; + + } catch (error) { + console.error('[FCM] Failed to send token:', error); + return false; + } +} + +/** + * 토큰 해제 (로그아웃 시 호출) + */ +export async function unregisterFCMToken(): Promise { + if (typeof window === 'undefined') return true; + + const token = sessionStorage.getItem(CONFIG.fcmTokenKey); + if (!token) return true; + + try { + // Next.js 프록시 경로 사용 + await fetch(`${CONFIG.proxyBasePath}/push/unregister-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ token }), + }); + } catch (e) { + console.warn('[FCM] Unregister failed, clearing local token'); + } + + sessionStorage.removeItem(CONFIG.fcmTokenKey); + isInitialized = false; + console.log('[FCM] Token unregistered'); + return true; +} + +/** + * 포그라운드 사운드 재생 + */ +function handleForegroundSound(soundKey?: string): void { + if (!isAppForeground) return; + if (!soundKey) return; + + try { + const soundPath = `${CONFIG.soundBasePath}${soundKey}.wav`; + const audio = new Audio(soundPath); + + audio.volume = 0.5; + audio.play().catch((err) => { + console.warn('[FCM] Sound play failed, trying default:', err.message); + // 기본 사운드 시도 + if (soundKey !== CONFIG.defaultSound) { + const defaultAudio = new Audio(`${CONFIG.soundBasePath}${CONFIG.defaultSound}.wav`); + defaultAudio.volume = 0.5; + defaultAudio.play().catch(() => {}); + } + }); + } catch (err) { + console.warn('[FCM] Sound error:', err); + } +} + +/** + * FCM 재초기화 (테스트/디버그용) + */ +export async function reinitializeFCM( + onForegroundNotification?: ForegroundNotificationHandler +): Promise { + isInitialized = false; + return initializeFCM(onForegroundNotification); +} + +/** + * 테스트용 사운드 재생 + */ +export function testPlaySound(soundKey: string): void { + handleForegroundSound(soundKey); +} + +// ===== 알림 타입별 스타일 ===== + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +/** + * 알림 타입에 따른 토스트 스타일 결정 + */ +export function getToastTypeByNotificationType(type?: string): ToastType { + if (!type) return 'info'; + + const typeMap: Record = { + // 긴급/에러 + invoice_failed: 'error', + payment_failed: 'error', + order_cancelled: 'error', + + // 경고 + approval_required: 'warning', + stock_low: 'warning', + + // 성공 + order_completed: 'success', + payment_completed: 'success', + approval_approved: 'success', + + // 기본 + default: 'info', + }; + + return typeMap[type] || 'info'; +} \ No newline at end of file