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