diff --git a/claudedocs/[REF] juil-pages-test-urls.md b/claudedocs/[REF] juil-pages-test-urls.md new file mode 100644 index 00000000..f76a8820 --- /dev/null +++ b/claudedocs/[REF] juil-pages-test-urls.md @@ -0,0 +1,42 @@ +# Juil Enterprise Test URLs +Last Updated: 2025-12-30 + +### 대시보드 +| 페이지 | URL | 상태 | +|---|---|---| +| **메인 대시보드** | `/ko/juil/dashboard` | ✅ 완료 | + +## 프로젝트 관리 (Project) +### 메인 +| 페이지 | URL | 상태 | +|---|---|---| +| **프로젝트 관리 메인** | `/ko/juil/project` | 🚧 구조잡기 | + +### 입찰관리 (Bidding) +| 페이지 | URL | 상태 | +|---|---|---| +| **거래처 관리** | `/ko/juil/project/bidding/partners` | ✅ 완료 | + +## 공사 관리 (Construction) +### 인수인계 / 실측 / 발주 / 시공 +| 페이지 | URL | 상태 | +|---|---|---| +| **공사 관리 메인** | `/ko/juil/construction` | 🚧 구조잡기 | + +## 현장 작업 (Field) +### 할당 / 인력 / 근태 / 보고 +| 페이지 | URL | 상태 | +|---|---|---| +| **현장 작업 메인** | `/ko/juil/field` | 🚧 구조잡기 | + +## 기성/정산 (Finance) +### 기성 / 변경계약 / 정산 +| 페이지 | URL | 상태 | +|---|---|---| +| **재무 관리 메인** | `/ko/juil/finance` | 🚧 구조잡기 | + +## 시스템 (System) +### 공통 +| 페이지 | URL | 상태 | +|---|---|---| +| **개발용 메뉴 목록** | `/ko/dev/juil-test-urls` | ✅ 완료 | diff --git a/claudedocs/[REF] juil-project-flow.md b/claudedocs/[REF] juil-project-flow.md new file mode 100644 index 00000000..42f8a608 --- /dev/null +++ b/claudedocs/[REF] juil-project-flow.md @@ -0,0 +1,82 @@ +# Juil Project Process Flow Analysis +Based on provided flowcharts. + +## 1. Project Progress Flow (Main Lifecycle) + +### Modules & Roles +| Role | Key Activities | Output/State | +|---|---|---| +| **Field Briefing User** | Attend briefing, Upload data | Project Initiated | +| **Estimate/Bid Manager** | Create Estimate (Approve/Return)
Bid Participation
Win/Loss Check | Estimate Created
Bid Submitted
Project Won/Lost | +| **Contract Manager** | Create Contract (Approve/Return)
Contract Execution
Handover Decision | Contract Finalized | +| **Order/Construction Manager** | Handover Creation (Approve/Return)
Field Measurement
Structural Review (if needed)
Order Creation (Approve/Return)
Construction Start | Handover Doc
Measurement Data
Structural Report
Order Placed | +| **Progress Billing Manager** | Create Progress Billing (Approve/Return)
Change Contract Check
Client Approval
Settlement | Bill Created
Settlement Complete | + +--- + +## 2. Construction & Billing Detail Flow + +### Detailed Steps by Role + +#### Order Manager +1. **Handover**: Create handover document -> Approval Loop. +2. **Field Work**: Field Measurement. +3. **Engineering**: Structural Review (Condition: if needed). +4. **Ordering**: Create Order -> Approval Loop. + +#### Construction Manager +1. **Execution**: Start Construction. +2. **Resources**: Request Vehicles/Equipment. +3. **Management**: Construction Management -> Issue Check. +4. **Issue Handling**: Manage Issues if they arise. + +#### Work Foreman (Field) +1. **Assignment**: Receive Construction Assignment. +2. **Personnel**: Check New Personnel -> Sign up if needed. +3. **Attendance**: GPS Attendance Check. +4. **Daily Work**: + - Perform Construction Work. + - Photo Documentation. + - Work Report. + - Personnel Status Report. + +#### Progress Billing Manager +1. **Billing**: Create Progress Billing -> Approval Loop. +2. **Change Mgmt**: Check if Change Contract is needed. + - If needed: Trigger Contract Manager flow. +3. **Client**: Get Construction Company (Client) Approval. +4. **Finish**: Settlement. + +#### Contract Manager (Change Process) +1. **Drafting**: Create Change Contract (triggered by Billing). +2. **Approval**: Internal Approval Loop. +3. **Execution**: Change Contract Process. +4. **Client**: Get Construction Company (Client) Approval. +5. **Finish**: Change Contract Complete. + +--- + +## 3. Proposed Menu Structure (Juil) + +Based on the flow, the recommended menu structure is: + +- **Dashboard**: Overall Status +- **Project Management** (프로젝트 관리) + - Field Briefing (현장설명회) + - Estimates & Bids (견적/입찰) + - Contracts (계약관리) +- **Construction Management** (공사관리) + - Handovers (인수인계) + - Field Measurements (현장실측) + - Structural Reviews (구조검토) + - Orders (발주관리) + - Construction Execution (시공관리) - Includes Vehicles, Issues +- **Field Work** (현장작업) - Mobile Optimized? + - My Assignments (시공할당) + - Personnel Mgmt (인력관리) + - Attendance (GPS출근) + - Daily Reports (업무보고/사진) +- **Billing & Settlement** (기성/정산) + - Progress Billing (기성청구) + - Change Contracts (변경계약) + - Settlements (정산관리) diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 77f102a9..15bf9e85 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-22) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-30) ## ⭐ 빠른 참조 @@ -19,7 +19,7 @@ claudedocs/ ├── hr/ # 👥 인사관리 (부서/사원) ├── item-master/ # 📦 품목기준관리 ├── production/ # 🏭 생산관리 (생산현황판/작업지시) -├── quality/ # 🔬 품질관리 (검사관리) (NEW) +├── quality/ # 🔬 품질관리 (검사관리) ├── sales/ # 💰 판매관리 (견적/거래처) ├── accounting/ # 💳 회계관리 (매입/매출/출금) ├── board/ # 📝 게시판 관리 @@ -28,6 +28,7 @@ claudedocs/ ├── api/ # 🔌 API 통합 ├── guides/ # 📚 범용 가이드 ├── architecture/ # 🏗️ 아키텍처 & 시스템 +├── juil/ # 🏗️ 주일 공사 MES (NEW) └── archive/ # 📁 레거시/완료된 문서 ``` @@ -37,6 +38,7 @@ claudedocs/ | 파일 | 설명 | |------|------| +| `[IMPL-2025-12-30] token-refresh-caching.md` | 🔴 **NEW** - 토큰 갱신 캐싱 구현 (동시 요청 충돌 해결, Request Coalescing 패턴) | | `[IMPL-2025-12-04] signup-page-blocking.md` | ✅ **완료** - MVP 회원가입 페이지 차단 (운영 페이지 이동 예정) | | `token-management-guide.md` | ⭐ **핵심** - Access/Refresh Token 완전 가이드 | | `jwt-cookie-authentication-final.md` | JWT + HttpOnly Cookie 구현 | @@ -208,6 +210,20 @@ claudedocs/ --- +## 🏗️ juil/ - 주일 공사 MES (NEW) + +| 파일 | 설명 | +|------|------| +| `[REF] juil-project-structure.md` | 🔴 **NEW** - 주일 프로젝트 구조 가이드 (경로, 컴포넌트, 테스트 URL) | + +**프로젝트 정보**: +- 업체: 주일 (공사/건설) +- 페이지 경로: `src/app/[locale]/(protected)/juil/` +- 컴포넌트: `src/components/business/juil/` +- 테스트 URL: http://localhost:3000/dev/juil-test-urls + +--- + ## 📁 archive/ - 레거시/완료된 문서 완료되거나 더 이상 활성화되지 않은 문서들. 참조용으로 보관. diff --git a/claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md b/claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md new file mode 100644 index 00000000..a06d1c56 --- /dev/null +++ b/claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md @@ -0,0 +1,262 @@ +# Fetch Wrapper Migration Checklist + +**생성일**: 2025-12-30 +**목적**: 모든 Server Actions의 API 통신을 `serverFetch`로 중앙화 + +## 목적 및 배경 + +### 왜 fetch-wrapper를 도입했는가? + +1. **중앙화된 인증 처리** + - 401 에러(세션 만료) 발생 시 → 로그인 페이지 리다이렉트 + - 모든 API 호출에서 **일관된 인증 검증** + +2. **개발 규칙 표준화** + - 새 작업자도 `serverFetch` 사용하면 자동으로 인증 검증 적용 + - 개별 파일마다 인증 로직 구현 불필요 + +3. **유지보수성 향상** + - 인증 로직 변경 시 **`fetch-wrapper.ts` 한 파일만** 수정 + - 403, 네트워크 에러 등 공통 에러 처리도 중앙화 + +--- + +## 마이그레이션 패턴 + +### Before (기존 패턴) +```typescript +import { cookies } from 'next/headers'; + +async function getApiHeaders(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get('access_token')?.value; + return { + 'Authorization': token ? `Bearer ${token}` : '', + // ... + }; +} + +export async function getSomething() { + const headers = await getApiHeaders(); + const response = await fetch(url, { headers }); + // 401 처리 없음! +} +``` + +### After (새 패턴) +```typescript +import { serverFetch } from '@/lib/api/fetch-wrapper'; + +export async function getSomething() { + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error) { + // 401/403/네트워크 에러 자동 처리됨 + return { success: false, error: error.message }; + } + + const data = await response.json(); + // ... +} +``` + +--- + +## 마이그레이션 체크리스트 + +### Accounting 도메인 (12 files) ✅ 완료 +- [x] `SalesManagement/actions.ts` +- [x] `VendorManagement/actions.ts` +- [x] `PurchaseManagement/actions.ts` +- [x] `DepositManagement/actions.ts` +- [x] `WithdrawalManagement/actions.ts` +- [x] `VendorLedger/actions.ts` +- [x] `ReceivablesStatus/actions.ts` +- [x] `ExpectedExpenseManagement/actions.ts` +- [x] `CardTransactionInquiry/actions.ts` +- [x] `DailyReport/actions.ts` +- [x] `BadDebtCollection/actions.ts` +- [x] `BankTransactionInquiry/actions.ts` + +### HR 도메인 (6 files) ✅ 완료 +- [x] `EmployeeManagement/actions.ts` ✅ (이미 마이그레이션됨) +- [x] `VacationManagement/actions.ts` ✅ +- [x] `SalaryManagement/actions.ts` ✅ +- [x] `CardManagement/actions.ts` ✅ +- [x] `DepartmentManagement/actions.ts` ✅ +- [x] `AttendanceManagement/actions.ts` ✅ + +### Approval 도메인 (4 files) ✅ 완료 +- [x] `ApprovalBox/actions.ts` +- [x] `DraftBox/actions.ts` +- [x] `ReferenceBox/actions.ts` +- [x] `DocumentCreate/actions.ts` (파일 업로드는 직접 fetch 유지) + +### Production 도메인 (4 files) ✅ 완료 +- [x] `WorkerScreen/actions.ts` +- [x] `WorkOrders/actions.ts` +- [x] `WorkResults/actions.ts` +- [x] `ProductionDashboard/actions.ts` + +### Settings 도메인 (10 files) ✅ 완료 +- [x] `WorkScheduleManagement/actions.ts` +- [x] `SubscriptionManagement/actions.ts` +- [x] `PopupManagement/actions.ts` +- [x] `PaymentHistoryManagement/actions.ts` +- [x] `LeavePolicyManagement/actions.ts` +- [x] `NotificationSettings/actions.ts` +- [x] `AttendanceSettingsManagement/actions.ts` +- [x] `CompanyInfoManagement/actions.ts` +- [x] `AccountInfoManagement/actions.ts` +- [x] `AccountManagement/actions.ts` + +### 기타 도메인 (12 files) ✅ 완료 +- [x] `process-management/actions.ts` +- [x] `outbound/ShipmentManagement/actions.ts` +- [x] `material/StockStatus/actions.ts` +- [x] `material/ReceivingManagement/actions.ts` +- [x] `customer-center/shared/actions.ts` +- [x] `board/actions.ts` +- [x] `reports/actions.ts` +- [x] `quotes/actions.ts` +- [x] `board/BoardManagement/actions.ts` +- [x] `attendance/actions.ts` +- [x] `pricing/actions.ts` +- [x] `quality/InspectionManagement/actions.ts` + +--- + +## 진행 상황 + +| 도메인 | 파일 수 | 완료 | 상태 | +|--------|---------|------|------| +| Accounting | 12 | 12 | ✅ 완료 | +| HR | 6 | 6 | ✅ 완료 | +| Approval | 4 | 4 | ✅ 완료 | +| Production | 4 | 4 | ✅ 완료 | +| Settings | 10 | 10 | ✅ 완료 | +| 기타 | 12 | 12 | ✅ 완료 | +| **총계** | **48** | **48** | **100%** ✅ | + +### 완료된 파일 (완전 마이그레이션) + +**Accounting 도메인 (12/12)** +- [x] `SalesManagement/actions.ts` +- [x] `VendorManagement/actions.ts` +- [x] `PurchaseManagement/actions.ts` +- [x] `DepositManagement/actions.ts` +- [x] `WithdrawalManagement/actions.ts` +- [x] `VendorLedger/actions.ts` +- [x] `ReceivablesStatus/actions.ts` +- [x] `ExpectedExpenseManagement/actions.ts` +- [x] `CardTransactionInquiry/actions.ts` +- [x] `DailyReport/actions.ts` +- [x] `BadDebtCollection/actions.ts` +- [x] `BankTransactionInquiry/actions.ts` + +**HR 도메인 (6/6)** +- [x] `EmployeeManagement/actions.ts` (이미 마이그레이션됨) +- [x] `VacationManagement/actions.ts` +- [x] `SalaryManagement/actions.ts` +- [x] `CardManagement/actions.ts` +- [x] `DepartmentManagement/actions.ts` +- [x] `AttendanceManagement/actions.ts` + +**Approval 도메인 (4/4)** +- [x] `ApprovalBox/actions.ts` +- [x] `DraftBox/actions.ts` +- [x] `ReferenceBox/actions.ts` +- [x] `DocumentCreate/actions.ts` (파일 업로드는 직접 fetch 유지) + +**Production 도메인 (4/4)** +- [x] `WorkerScreen/actions.ts` +- [x] `WorkOrders/actions.ts` +- [x] `WorkResults/actions.ts` +- [x] `ProductionDashboard/actions.ts` + +**Settings 도메인 (10/10)** +- [x] `WorkScheduleManagement/actions.ts` +- [x] `SubscriptionManagement/actions.ts` +- [x] `PopupManagement/actions.ts` +- [x] `PaymentHistoryManagement/actions.ts` +- [x] `LeavePolicyManagement/actions.ts` +- [x] `NotificationSettings/actions.ts` +- [x] `AttendanceSettingsManagement/actions.ts` +- [x] `CompanyInfoManagement/actions.ts` +- [x] `AccountInfoManagement/actions.ts` +- [x] `AccountManagement/actions.ts` + +**기타 도메인 (12/12)** ✅ 완료 +- [x] `process-management/actions.ts` +- [x] `outbound/ShipmentManagement/actions.ts` +- [x] `material/StockStatus/actions.ts` +- [x] `material/ReceivingManagement/actions.ts` +- [x] `customer-center/shared/actions.ts` +- [x] `board/actions.ts` +- [x] `reports/actions.ts` +- [x] `quotes/actions.ts` +- [x] `board/BoardManagement/actions.ts` +- [x] `attendance/actions.ts` +- [x] `pricing/actions.ts` +- [x] `quality/InspectionManagement/actions.ts` + +--- + +## 참조 파일 + +- **fetch-wrapper**: `src/lib/api/fetch-wrapper.ts` +- **errors**: `src/lib/api/errors.ts` +- **완료된 예시**: `src/components/accounting/BillManagement/actions.ts` (참고용) + +--- + +## 주의사항 + +1. **기존 `getApiHeaders()` 함수 제거** - `serverFetch`가 헤더 자동 생성 +2. **`import { cookies } from 'next/headers'` 제거** - wrapper에서 처리 +3. **에러 응답 구조 맞추기** - `{ success: false, error: string }` 형태 유지 +4. **빌드 테스트 필수** - 마이그레이션 후 `npm run build` 확인 + +--- + +## 🔜 추가 작업 (마이그레이션 완료 후) + +### Phase 2: 리프레시 토큰 자동 갱신 적용 + +**현재 문제:** +- access_token 만료 시 (약 2시간) 바로 로그인 리다이렉트됨 +- refresh_token (7일)을 사용한 자동 갱신 로직이 호출되지 않음 +- 결과: 40분~2시간 후 세션 만료 → 재로그인 필요 + +**목표:** +- 401 발생 시 → 리프레시 토큰으로 갱신 시도 → 성공 시 재시도 +- 7일간 세션 유지 (refresh_token 만료 시에만 재로그인) + +**적용 범위:** + +| 영역 | 적용 위치 | 작업 | +|------|----------|------| +| Server Actions | `fetch-wrapper.ts` | 401 시 리프레시 후 재시도 로직 추가 | +| 품목관리 | `ItemListClient.tsx` 등 | 클라이언트 fetch에 리프레시 로직 추가 | +| 품목기준관리 | 관련 컴포넌트들 | 클라이언트 fetch에 리프레시 로직 추가 | + +**관련 파일:** +- `src/lib/auth/token-refresh.ts` - 리프레시 함수 (이미 존재) +- `src/app/api/auth/refresh/route.ts` - 리프레시 API (이미 존재) + +**예상 구현:** +```typescript +// fetch-wrapper.ts 401 처리 부분 +if (response.status === 401 && !options?.skipAuthCheck) { + // 1. 리프레시 토큰으로 갱신 시도 + const refreshResult = await refreshTokenServer(refreshToken); + + if (refreshResult.success) { + // 2. 새 토큰으로 원래 요청 재시도 + return serverFetch(url, { ...options, skipAuthCheck: true }); + } + + // 3. 리프레시도 실패하면 로그인 리다이렉트 + redirect('/login'); +} +``` diff --git a/claudedocs/api/[NEXT-2025-12-30] fetch-wrapper-session-context.md b/claudedocs/api/[NEXT-2025-12-30] fetch-wrapper-session-context.md new file mode 100644 index 00000000..14fc37a8 --- /dev/null +++ b/claudedocs/api/[NEXT-2025-12-30] fetch-wrapper-session-context.md @@ -0,0 +1,78 @@ +ㅏ# 세션 요약 (2025-12-30) + +## 완료된 작업 + +### 1. fetch-wrapper 목적 확인 +- **목적**: 401 에러(세션 만료) 발생 시 로그인 리다이렉트를 **중앙화** +- **장점**: 중복 코드 제거 + 새 작업자도 규칙 준수 가능 + +### 2. Accounting 도메인 완료 (12/12) ✅ +- [x] `SalesManagement/actions.ts` +- [x] `VendorManagement/actions.ts` +- [x] `PurchaseManagement/actions.ts` +- [x] `DepositManagement/actions.ts` +- [x] `WithdrawalManagement/actions.ts` +- [x] `VendorLedger/actions.ts` +- [x] `ReceivablesStatus/actions.ts` +- [x] `ExpectedExpenseManagement/actions.ts` +- [x] `CardTransactionInquiry/actions.ts` +- [x] `DailyReport/actions.ts` +- [x] `BadDebtCollection/actions.ts` +- [x] `BankTransactionInquiry/actions.ts` + +### 3. HR 도메인 진행중 (1/6) +- [x] `EmployeeManagement/actions.ts` (이미 마이그레이션되어 있었음) +- [~] `VacationManagement/actions.ts` (import만 변경됨, 함수 마이그레이션 필요) + +## 다음 세션 TODO + +### HR 도메인 나머지 (5개) +- [ ] `VacationManagement/actions.ts` - 함수 마이그레이션 완료 필요 +- [ ] `SalaryManagement/actions.ts` +- [ ] `CardManagement/actions.ts` +- [ ] `DepartmentManagement/actions.ts` +- [ ] `AttendanceManagement/actions.ts` + +### 기타 도메인 (Approval, Production, Settings, 기타) +- Approval: 4개 +- Production: 4개 +- Settings: 11개 +- 기타: 12개 +- 상세 목록은 체크리스트 문서 참고 + +### 빌드 검증 +- [ ] `npm run build` 실행하여 마이그레이션 검증 + +## 참고 사항 + +### 마이그레이션 패턴 (참고용) +```typescript +// Before +import { cookies } from 'next/headers'; +async function getApiHeaders() { ... } +const response = await fetch(url, { headers }); + +// After +import { serverFetch } from '@/lib/api/fetch-wrapper'; +const { response, error } = await serverFetch(url, { method: 'GET' }); +if (error) return { success: false, error: error.message }; +``` + +### 주요 변경 포인트 +1. `getApiHeaders()` 함수 제거 +2. `import { cookies } from 'next/headers'` 제거 +3. `fetch()` → `serverFetch()` 변경 +4. `{ response, error }` 구조분해 사용 +5. 파일 다운로드(Excel/PDF)는 `cookies` import 유지 (custom Accept 헤더 필요) + +### 특이사항 +- `EmployeeManagement/actions.ts`는 이미 `serverFetch` 사용 중이었음 +- `uploadProfileImage` 함수는 FormData 업로드라 `cookies` import 유지 + +## 체크리스트 문서 +`claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md` + +## 진행률 +- 전체: 49개 파일 +- 완료: 13개 (27%) +- 남음: 36개 diff --git a/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md b/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md new file mode 100644 index 00000000..392de785 --- /dev/null +++ b/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md @@ -0,0 +1,329 @@ +# Token Refresh Caching 구현 문서 + +> 작성일: 2025-12-30 +> 상태: 완료 + +## 1. 문제 상황 + +### 1.1 증상 +페이지 로드 시 여러 API 호출이 동시에 발생할 때, 일부 요청이 401 에러와 함께 실패하고 로그인 페이지로 리다이렉트되는 현상. + +### 1.2 원인 분석 +`useEffect`에서 여러 API를 동시에 호출할 때 **refresh_token 충돌** 발생: + +``` +시간 → +──────────────────────────────────────────────────────────────────── +[요청 A] access_token 만료 → 401 → refresh_token 사용 → ✅ 새 토큰 발급 (기존 refresh_token 폐기) +[요청 B] access_token 만료 → 401 → refresh_token 사용 → ❌ 실패 (이미 폐기된 토큰) +[요청 C] access_token 만료 → 401 → refresh_token 사용 → ❌ 실패 (이미 폐기된 토큰) +──────────────────────────────────────────────────────────────────── +``` + +**핵심 문제**: refresh_token은 일회용(One-Time Use)이므로, 첫 번째 요청이 사용하면 즉시 폐기됨. + +### 1.3 영향 범위 +- **Proxy 경로** (`/api/proxy/*`): 클라이언트 → Next.js → PHP 백엔드 +- **Server Actions** (`serverFetch`): Server Component에서 직접 API 호출 + +--- + +## 2. 해결 방법: Request Coalescing (요청 병합) 패턴 + +### 2.1 패턴 설명 +동시에 발생하는 동일한 요청을 하나로 병합하여 처리하는 표준 패턴. + +``` +시간 → +──────────────────────────────────────────────────────────────────── +[요청 A] 401 → refresh 시작 (Promise 생성) → ✅ 새 토큰 → 캐시 저장 +[요청 B] 401 → 캐시된 Promise 대기 ────────→ ✅ 같은 새 토큰 사용 +[요청 C] 401 → 캐시된 Promise 대기 ────────→ ✅ 같은 새 토큰 사용 +──────────────────────────────────────────────────────────────────── +``` + +### 2.2 구현 특징 +- **5초 캐싱**: refresh 결과를 5초간 캐시 +- **Promise 공유**: 진행 중인 refresh Promise를 여러 요청이 공유 +- **모듈 레벨 캐시**: Proxy와 serverFetch가 동일한 캐시 공유 + +--- + +## 3. 구현 코드 + +### 3.1 파일 구조 +``` +src/lib/api/ +├── refresh-token.ts # 🆕 공통 토큰 갱신 모듈 (캐싱 로직 포함) +├── fetch-wrapper.ts # serverFetch (import from refresh-token) +└── errors.ts # 에러 타입 정의 + +src/app/api/proxy/ +└── [...path]/route.ts # Proxy (import from refresh-token) +``` + +### 3.2 공통 모듈: `refresh-token.ts` + +```typescript +/** + * 🔄 Refresh Token 공통 모듈 + * + * 문제: useEffect에서 여러 API 동시 호출 시 refresh_token 충돌 + * 해결: 5초간 refresh 결과 캐싱 + Promise 공유 + */ + +export type RefreshResult = { + success: boolean; + accessToken?: string; + refreshToken?: string; + expiresIn?: number; +}; + +// 캐시 상태 (모듈 레벨에서 공유) +let refreshCache: { + promise: Promise | null; + timestamp: number; + result: RefreshResult | null; +} = { + promise: null, + timestamp: 0, + result: null, +}; + +const REFRESH_CACHE_TTL = 5000; // 5초 + +/** + * 실제 토큰 갱신 수행 (내부 함수) + */ +async function doRefreshToken(refreshToken: string): Promise { + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-API-KEY': process.env.API_KEY || '', + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + return { success: false }; + } + + const data = await response.json(); + return { + success: true, + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + }; + } catch (error) { + console.error('🔴 [RefreshToken] Token refresh error:', error); + return { success: false }; + } +} + +/** + * 토큰 갱신 함수 (5초 캐싱 적용) + * + * 동시 요청 시: + * 1. 캐시된 결과가 있으면 즉시 반환 + * 2. 진행 중인 refresh가 있으면 그 Promise를 기다림 + * 3. 둘 다 없으면 새 refresh 시작 + */ +export async function refreshAccessToken( + refreshToken: string, + caller: string = 'unknown' +): Promise { + const now = Date.now(); + + // 1. 캐시된 결과가 유효하면 즉시 반환 + if (refreshCache.result?.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) { + console.log(`🔵 [${caller}] Using cached refresh result`); + return refreshCache.result; + } + + // 2. 진행 중인 refresh가 있으면 그 결과를 기다림 + if (refreshCache.promise && now - refreshCache.timestamp < REFRESH_CACHE_TTL) { + console.log(`🔵 [${caller}] Waiting for ongoing refresh...`); + return refreshCache.promise; + } + + // 3. 새 refresh 시작 + console.log(`🔄 [${caller}] Starting new refresh request...`); + refreshCache.timestamp = now; + refreshCache.result = null; + + refreshCache.promise = doRefreshToken(refreshToken).then(result => { + refreshCache.result = result; + return result; + }); + + return refreshCache.promise; +} +``` + +### 3.3 사용 예시 + +**Proxy에서 사용:** +```typescript +// src/app/api/proxy/[...path]/route.ts +import { refreshAccessToken } from '@/lib/api/refresh-token'; + +// 401 응답 시 +const refreshResult = await refreshAccessToken(refreshToken, 'PROXY'); +``` + +**serverFetch에서 사용:** +```typescript +// src/lib/api/fetch-wrapper.ts +import { refreshAccessToken } from './refresh-token'; + +// 401 응답 시 +const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch'); +``` + +--- + +## 4. 시행착오 기록 + +### 4.1 초기 문제: 중복 구현 +처음에는 Proxy와 serverFetch에서 각각 캐싱 로직을 별도로 구현했음. + +**문제점:** +- 코드 중복 (~80줄씩) +- 두 캐시가 분리되어 있어 비효율적 +- 유지보수 어려움 + +**해결:** 공통 모듈 `refresh-token.ts`로 통합 + +### 4.2 빌드 오류: .next 폴더 손상 +``` +Error: Cannot find module './4586.js' +``` + +**원인:** 이전 빌드 아티팩트와 새 코드 간 충돌 + +**해결:** +```bash +rm -rf .next +npm run build +``` + +### 4.3 런타임 오류: app-paths-manifest.json 누락 +``` +500 Error: .next/server/app-paths-manifest.json not found +``` + +**원인:** 빌드 중 .next 폴더 손상 + +**해결:** +```bash +rm -rf .next +npm run dev +``` + +### 4.4 Safari 호환성 문제 (이전 세션에서 해결) +Safari에서 `SameSite=Strict` + `Secure` 조합이 localhost에서 쿠키 저장 실패. + +**해결:** +- `SameSite=Strict` → `SameSite=Lax` +- `Secure`는 프로덕션에서만 적용 + +--- + +## 5. 동작 흐름도 + +### 5.1 정상 흐름 (토큰 유효) +``` +클라이언트 → Proxy/serverFetch → API 요청 → 200 OK → 응답 반환 +``` + +### 5.2 토큰 갱신 흐름 (단일 요청) +``` +클라이언트 → Proxy/serverFetch → API 요청 → 401 + ↓ + refreshAccessToken() + ↓ + 새 토큰 발급 + 쿠키 저장 + ↓ + 원래 요청 재시도 → 200 OK +``` + +### 5.3 토큰 갱신 흐름 (동시 요청 - 캐싱 적용) +``` +[요청 A] → 401 → refreshAccessToken() → 새 refresh 시작 ──┐ +[요청 B] → 401 → refreshAccessToken() → Promise 대기 ────┼→ 같은 새 토큰 공유 +[요청 C] → 401 → refreshAccessToken() → Promise 대기 ────┘ + ↓ + 각자 원래 요청 재시도 +``` + +--- + +## 6. 설정 값 + +| 항목 | 값 | 설명 | +|------|-----|------| +| REFRESH_CACHE_TTL | 5초 | refresh 결과 캐시 유지 시간 | +| access_token Max-Age | 7200초 (2시간) | API에서 전달받은 값 사용 | +| refresh_token Max-Age | 604800초 (7일) | 장기 보관 | + +--- + +## 7. 로그 메시지 + +### 7.1 캐시 히트 (이미 갱신된 토큰 재사용) +``` +🔵 [PROXY] Using cached refresh result (age: 1234ms) +🔵 [serverFetch] Using cached refresh result (age: 1234ms) +``` + +### 7.2 대기 중 (다른 요청이 갱신 중) +``` +🔵 [PROXY] Waiting for ongoing refresh... +🔵 [serverFetch] Waiting for ongoing refresh... +``` + +### 7.3 새 갱신 시작 +``` +🔄 [PROXY] Starting new refresh request... +🔄 [serverFetch] Starting new refresh request... +✅ [RefreshToken] Token refreshed successfully +``` + +### 7.4 갱신 실패 +``` +🔴 [RefreshToken] Token refresh failed: { status: 401, ... } +``` + +--- + +## 8. 관련 파일 + +| 파일 | 역할 | +|------|------| +| `src/lib/api/refresh-token.ts` | 공통 토큰 갱신 모듈 (캐싱 로직) | +| `src/lib/api/fetch-wrapper.ts` | Server Actions용 fetch wrapper | +| `src/app/api/proxy/[...path]/route.ts` | 클라이언트 API 프록시 | +| `src/app/api/auth/login/route.ts` | 로그인 및 초기 토큰 설정 | + +--- + +## 9. 이 패턴이 "편법"이 아닌 이유 + +### 9.1 업계 표준 패턴 +- **Request Coalescing / Request Deduplication**: 공식 명칭 +- React Query, SWR, Apollo Client 등에서 동일 패턴 사용 +- CDN (Cloudflare, Fastly)에서도 동일 원리 적용 + +### 9.2 설계 원칙 준수 +- **DRY**: 중복 요청 제거 +- **효율성**: 서버 부하 감소 +- **일관성**: 모든 요청이 같은 새 토큰 사용 + +### 9.3 향후 위험성 없음 +- 5초 TTL은 충분히 짧아 토큰 갱신 지연 문제 없음 +- 실패 시 다음 요청에서 새로 갱신 시도 +- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화 \ No newline at end of file diff --git a/claudedocs/juil/[REF] juil-project-structure.md b/claudedocs/juil/[REF] juil-project-structure.md new file mode 100644 index 00000000..771bf246 --- /dev/null +++ b/claudedocs/juil/[REF] juil-project-structure.md @@ -0,0 +1,89 @@ +# 주일 공사 MES 프로젝트 구조 + +Last Updated: 2025-12-30 + +## 프로젝트 개요 + +| 항목 | 내용 | +|------|------| +| 업체명 | 주일 | +| 업종 | 공사 (건설/시공) | +| 프로젝트 유형 | MES (Manufacturing Execution System) | +| 기존 프로젝트 | 경동 (셔터 업체) | + +## 디렉토리 구조 + +``` +src/app/[locale]/(protected)/ +├── juil/ # 주일 전용 페이지들 +│ ├── page.tsx # 메인 페이지 (예정) +│ ├── [기능명]/ # 각 기능별 페이지 +│ └── ... +│ +├── dev/ +│ └── juil-test-urls/ # 테스트 URL 관리 페이지 +│ ├── page.tsx # 서버 컴포넌트 (MD 파싱) +│ └── JuilTestUrlsClient.tsx # 클라이언트 컴포넌트 +│ +└── (기존 경동 페이지들) +``` + +## 컴포넌트 구조 (예정) + +``` +src/components/business/juil/ # 주일 전용 비즈니스 컴포넌트 +├── common/ # 공통 컴포넌트 +├── [기능명]/ # 기능별 컴포넌트 +└── ... +``` + +## 테스트 URL 페이지 + +| 항목 | 내용 | +|------|------| +| URL | http://localhost:3000/dev/juil-test-urls | +| MD 파일 | `claudedocs/[REF] juil-pages-test-urls.md` | +| 용도 | 개발 중인 주일 페이지 URL 관리 및 빠른 접근 | + +### MD 파일 형식 + +```markdown +## 카테고리명 + +| 페이지 | URL | 상태 | +|--------|-----|------| +| **페이지명** | `/ko/juil/...` | 상태표시 | +``` + +## 경동 vs 주일 비교 + +| 항목 | 경동 | 주일 | +|------|------|------| +| 업종 | 셔터 | 공사 | +| 경로 | `/ko/...` (기존 경로) | `/ko/juil/...` | +| 컴포넌트 | `src/components/...` | `src/components/business/juil/...` | +| 문서 | `claudedocs/...` | `claudedocs/juil/...` | + +## 개발 가이드 + +### 새 페이지 추가 시 + +1. `src/app/[locale]/(protected)/juil/[기능명]/` 폴더 생성 +2. `page.tsx` 생성 +3. 필요 시 `src/components/business/juil/[기능명]/` 컴포넌트 생성 +4. `claudedocs/[REF] juil-pages-test-urls.md`에 URL 추가 + +### 테스트 URL 등록 + +`claudedocs/[REF] juil-pages-test-urls.md` 파일에 마크다운 테이블 형식으로 추가: + +```markdown +| **새페이지** | `/ko/juil/new-page` | NEW | +``` + +## 관련 파일 목록 + +- `claudedocs/[REF] juil-pages-test-urls.md` - 테스트 URL 목록 +- `claudedocs/juil/` - 주일 프로젝트 문서 폴더 +- `src/app/[locale]/(protected)/juil/` - 페이지 파일 +- `src/components/business/juil/` - 컴포넌트 파일 \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 4fa6e946..a83f31b9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,11 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility + experimental: { + serverActions: { + bodySizeLimit: '10mb', // 이미지 업로드를 위한 제한 증가 + }, + }, typescript: { // ⚠️ WARNING: This allows production builds to complete even with TypeScript errors // Only use during development. Remove for production deployments. diff --git a/src/app/[locale]/(protected)/dev/juil-test-urls/JuilTestUrlsClient.tsx b/src/app/[locale]/(protected)/dev/juil-test-urls/JuilTestUrlsClient.tsx new file mode 100644 index 00000000..bf344c0b --- /dev/null +++ b/src/app/[locale]/(protected)/dev/juil-test-urls/JuilTestUrlsClient.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ExternalLink, Copy, Check, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'; + +export interface UrlItem { + name: string; + url: string; + status?: string; +} + +export interface UrlCategory { + title: string; + icon: string; + items: UrlItem[]; + subCategories?: { + title: string; + items: UrlItem[]; + }[]; +} + +interface TestUrlsClientProps { + initialData: UrlCategory[]; + lastUpdated: string; +} + +function UrlCard({ item, baseUrl }: { item: UrlItem; baseUrl: string }) { + const [copied, setCopied] = useState(false); + const fullUrl = `${baseUrl}${item.url}`; + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + await navigator.clipboard.writeText(fullUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleOpen = () => { + window.open(fullUrl, '_blank'); + }; + + return ( +
+
+
+ + {item.name} + + {item.status && ( + + {item.status} + + )} +
+

+ {item.url} +

+
+
+ + +
+
+ ); +} + +function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl: string }) { + const [expanded, setExpanded] = useState(true); + const [subExpanded, setSubExpanded] = useState>({}); + + const toggleSub = (title: string) => { + setSubExpanded((prev) => ({ ...prev, [title]: !prev[title] })); + }; + + const totalItems = category.items.length + + (category.subCategories?.reduce((acc, sub) => acc + sub.items.length, 0) || 0); + + if (totalItems === 0) return null; + + return ( +
+ + + {expanded && ( +
+ {category.items.length > 0 && ( +
+ {category.items.map((item) => ( + + ))} +
+ )} + + {category.subCategories?.map((sub) => ( +
+ + {subExpanded[sub.title] !== false && ( +
+ {sub.items.map((item) => ( + + ))} +
+ )} +
+ ))} +
+ )} +
+ ); +} + +export default function JuilTestUrlsClient({ initialData, lastUpdated }: TestUrlsClientProps) { + const [baseUrl, setBaseUrl] = useState('http://localhost:3000'); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + if (typeof window !== 'undefined') { + setBaseUrl(window.location.origin); + } + }, []); + + // 검색 필터링 + const filteredData = initialData + .map((category) => ({ + ...category, + items: category.items.filter( + (item) => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + item.url.toLowerCase().includes(searchTerm.toLowerCase()) + ), + subCategories: category.subCategories?.map((sub) => ({ + ...sub, + items: sub.items.filter( + (item) => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + item.url.toLowerCase().includes(searchTerm.toLowerCase()) + ), + })).filter((sub) => sub.items.length > 0), + })) + .filter( + (category) => + category.items.length > 0 || (category.subCategories && category.subCategories.length > 0) + ); + + const totalLinks = initialData.reduce( + (acc, cat) => + acc + + cat.items.length + + (cat.subCategories?.reduce((subAcc, sub) => subAcc + sub.items.length, 0) || 0), + 0 + ); + + const handleRefresh = () => { + window.location.reload(); + }; + + return ( +
+
+ {/* Header */} +
+
+

+ 🏭 주일기업 테스트 URL 목록 +

+ +
+

+ 주일기업용 백엔드 메뉴 연동 전 테스트용 직접 접근 URL ({totalLinks}개) +

+

+ 클릭하면 새 탭에서 열립니다 • 최종 업데이트: {lastUpdated} +

+

+ ✨ md 파일 수정 시 자동 반영됩니다 +

+
+ + {/* Search & Base URL */} +
+ setSearchTerm(e.target.value)} + className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ Base: + setBaseUrl(e.target.value)} + className="w-48 text-sm bg-transparent text-gray-900 dark:text-white focus:outline-none" + /> +
+
+ + {/* Categories */} +
+ {filteredData.map((category) => ( + + ))} +
+ + {filteredData.length === 0 && ( +
+ 검색 결과가 없습니다. +
+ )} + + {/* Footer */} +
+

+ 📁 데이터 소스: claudedocs/[REF] juil-pages-test-urls.md +

+

+ md 파일 수정 후 새로고침하면 자동 반영! +

+
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/dev/juil-test-urls/page.tsx b/src/app/[locale]/(protected)/dev/juil-test-urls/page.tsx new file mode 100644 index 00000000..95256451 --- /dev/null +++ b/src/app/[locale]/(protected)/dev/juil-test-urls/page.tsx @@ -0,0 +1,152 @@ + +import { promises as fs } from 'fs'; +import path from 'path'; +import JuilTestUrlsClient, { UrlCategory, UrlItem } from './JuilTestUrlsClient'; + +// 아이콘 매핑 +const iconMap: Record = { + '기본': '🏠', + '시스템': '💻', + '대시보드': '📊', +}; + +function getIcon(title: string): string { + for (const [key, icon] of Object.entries(iconMap)) { + if (title.includes(key)) return icon; + } + return '📄'; +} + +function parseTableRow(line: string): UrlItem | null { + // | 페이지 | URL | 상태 | 형식 파싱 + const parts = line.split('|').map(p => p.trim()).filter(p => p); + + if (parts.length < 2) return null; + if (parts[0] === '페이지' || parts[0].startsWith('---')) return null; + + const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거 + const url = parts[1].replace(/`/g, ''); // backtick 제거 + const status = parts[2] || undefined; + + // URL이 /ko로 시작하는지 확인 + if (!url.startsWith('/ko')) return null; + + return { name, url, status }; +} + +function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } { + const lines = content.split('\n'); + const categories: UrlCategory[] = []; + let currentCategory: UrlCategory | null = null; + let currentSubCategory: { title: string; items: UrlItem[] } | null = null; + let lastUpdated = 'N/A'; + + // Last Updated 추출 + const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/); + if (updateMatch) { + lastUpdated = updateMatch[1]; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // ## 카테고리 (메인 섹션) + if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) { + // 이전 카테고리 저장 + if (currentCategory) { + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + currentSubCategory = null; + } + categories.push(currentCategory); + } + + const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim(); + currentCategory = { + title, + icon: getIcon(title), + items: [], + subCategories: [], + }; + currentSubCategory = null; + } + + // ### 서브 카테고리 + else if (line.startsWith('### ') && currentCategory) { + // 이전 서브카테고리 저장 + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + } + + const subTitle = line.replace('### ', '').trim(); + // "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로 + if (subTitle === '메인 페이지') { + currentSubCategory = null; + } else { + currentSubCategory = { + title: subTitle, + items: [], + }; + } + } + + // 테이블 행 파싱 + else if (line.startsWith('|') && currentCategory) { + const item = parseTableRow(line); + if (item) { + if (currentSubCategory) { + currentSubCategory.items.push(item); + } else { + currentCategory.items.push(item); + } + } + } + } + + // 마지막 카테고리 저장 + if (currentCategory) { + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + } + categories.push(currentCategory); + } + + // 빈 서브카테고리 제거 + categories.forEach(cat => { + cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0); + }); + + return { categories, lastUpdated }; +} + +export default async function TestUrlsPage() { + // md 파일 경로 + const mdFilePath = path.join( + process.cwd(), + 'claudedocs', + '[REF] juil-pages-test-urls.md' + ); + + let urlData: UrlCategory[] = []; + let lastUpdated = 'N/A'; + + try { + const fileContent = await fs.readFile(mdFilePath, 'utf-8'); + const parsed = parseMdFile(fileContent); + urlData = parsed.categories; + lastUpdated = parsed.lastUpdated; + } catch (error) { + console.error('Failed to read md file:', error); + // 파일 읽기 실패 시 빈 데이터 + urlData = []; + } + + return ; +} + +// 캐싱 비활성화 - 항상 최신 md 파일 읽기 +export const dynamic = 'force-dynamic'; +export const revalidate = 0; diff --git a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx index 977a4558..962c5496 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx @@ -2,7 +2,7 @@ import { useRouter, useParams } from 'next/navigation'; import { useState, useEffect, useCallback } from 'react'; -import { EmployeeDetail } from '@/components/hr/EmployeeManagement/EmployeeDetail'; +import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm'; import { getEmployeeById, deleteEmployee } from '@/components/hr/EmployeeManagement/actions'; import { AlertDialog, @@ -87,7 +87,8 @@ export default function EmployeeDetailPage() { return ( <> - ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index d1829cdc..e36bca6b 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -3,6 +3,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard'; import AuthenticatedLayout from '@/layouts/AuthenticatedLayout'; import { RootProvider } from '@/contexts/RootProvider'; +import { ApiErrorProvider } from '@/contexts/ApiErrorContext'; /** * Protected Layout @@ -11,6 +12,7 @@ import { RootProvider } from '@/contexts/RootProvider'; * - Apply authentication guard to all protected pages * - Apply common layout (sidebar, header) to all protected pages * - Provide global context (RootProvider) + * - Provide API error handling context (ApiErrorProvider) * - Prevent browser back button cache issues * - Centralized protection for all routes under (protected) * @@ -30,9 +32,12 @@ export default function ProtectedLayout({ useAuthGuard(); // 🎨 모든 하위 페이지에 공통 레이아웃 및 Context 적용 + // 🚨 ApiErrorProvider: Server Action 401 에러 시 자동 로그인 리다이렉트 return ( - {children} + + {children} + ); } \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 0f52fb60..c52a131a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -157,7 +157,7 @@ export async function POST(request: NextRequest) { ...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix) 'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility) 'Path=/', - `Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours) + `Max-Age=${data.expires_in || 7200}`, ].join('; '); const refreshTokenCookie = [ diff --git a/src/app/api/files/[id]/download/route.ts b/src/app/api/files/[id]/download/route.ts new file mode 100644 index 00000000..256fe638 --- /dev/null +++ b/src/app/api/files/[id]/download/route.ts @@ -0,0 +1,65 @@ +/** + * 파일 다운로드 프록시 API + * + * 백엔드 파일 다운로드 API는 인증이 필요하므로, + * Next.js API 라우트를 통해 인증된 요청을 프록시합니다. + * + * GET /api/files/[id]/download + */ + +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const token = request.cookies.get('access_token')?.value; + + if (!token) { + return NextResponse.json( + { success: false, message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${id}/download`; + + const response = await fetch(backendUrl, { + headers: { + 'Authorization': `Bearer ${token}`, + 'X-API-KEY': process.env.API_KEY || '', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { success: false, message: '파일을 찾을 수 없습니다.' }, + { status: response.status } + ); + } + + // 파일 데이터와 헤더 전달 + const blob = await response.blob(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const contentDisposition = response.headers.get('content-disposition'); + + const headers: HeadersInit = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000', // 1년 캐시 + }; + + if (contentDisposition) { + headers['Content-Disposition'] = contentDisposition; + } + + return new NextResponse(blob, { headers }); + } catch (error) { + console.error('[FileDownload] Error:', error); + return NextResponse.json( + { success: false, message: '파일 다운로드 중 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index cce7d73a..86222390 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { refreshAccessToken } from '@/lib/api/refresh-token'; /** * 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern) @@ -30,48 +31,6 @@ import { NextRequest, NextResponse } from 'next/server'; * - 쿼리 파라미터와 요청 바디 모두 전달 */ -/** - * 토큰 갱신 함수 (access_token 만료 시 refresh_token으로 갱신) - */ -async function refreshAccessToken(refreshToken: string): Promise<{ - success: boolean; - accessToken?: string; - refreshToken?: string; - expiresIn?: number; -}> { - try { - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-API-KEY': process.env.API_KEY || '', - }, - body: JSON.stringify({ - refresh_token: refreshToken, - }), - }); - - if (!response.ok) { - console.warn('🔴 [PROXY] Token refresh failed'); - return { success: false }; - } - - const data = await response.json(); - console.log('✅ [PROXY] Token refreshed successfully'); - - return { - success: true, - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in, - }; - } catch (error) { - console.error('🔴 [PROXY] Token refresh error:', error); - return { success: false }; - } -} - /** * 백엔드 API 요청 실행 함수 * @@ -109,13 +68,14 @@ async function executeBackendRequest( */ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) { const cookies: string[] = []; + const isProduction = process.env.NODE_ENV === 'production'; if (tokens.accessToken) { cookies.push([ `access_token=${tokens.accessToken}`, 'HttpOnly', - 'Secure', - 'SameSite=Strict', + ...(isProduction ? ['Secure'] : []), // HTTPS only in production + 'SameSite=Lax', // Lax for better compatibility (matches login route) 'Path=/', `Max-Age=${tokens.expiresIn || 7200}`, ].join('; ')); @@ -125,8 +85,8 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin cookies.push([ `refresh_token=${tokens.refreshToken}`, 'HttpOnly', - 'Secure', - 'SameSite=Strict', + ...(isProduction ? ['Secure'] : []), // HTTPS only in production + 'SameSite=Lax', // Lax for better compatibility (matches login route) 'Path=/', 'Max-Age=604800', // 7 days ].join('; ')); @@ -139,9 +99,11 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin * 쿠키 삭제 헬퍼 함수 (토큰 만료 시) */ function createClearTokenCookies(): string[] { + const isProduction = process.env.NODE_ENV === 'production'; + const secureFlag = isProduction ? '; Secure' : ''; return [ - 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0', - 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0', + `access_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, + `refresh_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`, ]; } @@ -220,7 +182,7 @@ async function proxyRequest( if (backendResponse.status === 401 && refreshToken) { console.log('🔄 [PROXY] Got 401, attempting token refresh...'); - const refreshResult = await refreshAccessToken(refreshToken); + const refreshResult = await refreshAccessToken(refreshToken, 'PROXY'); if (refreshResult.success && refreshResult.accessToken) { console.log('✅ [PROXY] Token refreshed, retrying original request...'); @@ -232,19 +194,40 @@ async function proxyRequest( console.log('🔵 [PROXY] Retry response status:', backendResponse.status); } else { - // 리프레시 실패 → 쿠키 삭제하고 401 반환 - console.warn('🔴 [PROXY] Token refresh failed, clearing cookies...'); + // 🔄 리프레시 실패 → 다른 요청이 동시에 refresh 중일 수 있음 + // 짧은 딜레이 후 한 번 더 refresh 시도 + console.log('🔄 [PROXY] Refresh failed, waiting and retrying...'); + await new Promise(resolve => setTimeout(resolve, 500)); // 500ms 대기 - const clearResponse = NextResponse.json( - { error: 'Authentication failed', needsReauth: true }, - { status: 401 } - ); + // 다시 refresh 시도 (다른 요청이 새 refresh_token을 발급받았을 수 있음) + const latestRefreshToken = request.cookies.get('refresh_token')?.value; + if (latestRefreshToken) { + const retryResult = await refreshAccessToken(latestRefreshToken, 'PROXY'); - createClearTokenCookies().forEach(cookie => { - clearResponse.headers.append('Set-Cookie', cookie); - }); + if (retryResult.success && retryResult.accessToken) { + console.log('✅ [PROXY] Retry refresh succeeded!'); + token = retryResult.accessToken; + newTokens = retryResult; + backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData); + console.log('🔵 [PROXY] Retry response status:', backendResponse.status); + } + } - return clearResponse; + // 여전히 401이면 쿠키 삭제하고 401 반환 + if (backendResponse.status === 401) { + console.warn('🔴 [PROXY] Token refresh failed after retry, clearing cookies...'); + + const clearResponse = NextResponse.json( + { error: 'Authentication failed', needsReauth: true }, + { status: 401 } + ); + + createClearTokenCookies().forEach(cookie => { + clearResponse.headers.append('Set-Cookie', cookie); + }); + + return clearResponse; + } } } diff --git a/src/components/accounting/BadDebtCollection/actions.ts b/src/components/accounting/BadDebtCollection/actions.ts index d3b12742..da90881e 100644 --- a/src/components/accounting/BadDebtCollection/actions.ts +++ b/src/components/accounting/BadDebtCollection/actions.ts @@ -13,8 +13,8 @@ 'use server'; -import { cookies } from 'next/headers'; import { revalidatePath } from 'next/cache'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { BadDebtRecord, CollectionStatus } from './types'; // ============================================ @@ -118,21 +118,6 @@ interface BadDebtSummaryApiData { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 상태 → 프론트엔드 상태 변환 * API: legal_action, bad_debt (snake_case) @@ -267,7 +252,6 @@ export async function getBadDebts(params?: { client_id?: string; }): Promise { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -281,14 +265,15 @@ export async function getBadDebts(params?: { const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts?${searchParams.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[BadDebtActions] GET list error:', response.status); + if (error) { + console.error('[BadDebtActions] GET list error:', error.message); + return []; + } + + if (!response?.ok) { + console.error('[BadDebtActions] GET list error:', response?.status); return []; } @@ -311,19 +296,18 @@ export async function getBadDebts(params?: { */ export async function getBadDebtById(id: string): Promise { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - console.error('[BadDebtActions] GET detail error:', response.status); + if (error) { + console.error('[BadDebtActions] GET detail error:', error.message); + return null; + } + + if (!response?.ok) { + console.error('[BadDebtActions] GET detail error:', response?.status); return null; } @@ -345,19 +329,18 @@ export async function getBadDebtById(id: string): Promise */ export async function getBadDebtSummary(): Promise { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/summary`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - console.error('[BadDebtActions] GET summary error:', response.status); + if (error) { + console.error('[BadDebtActions] GET summary error:', error.message); + return null; + } + + if (!response?.ok) { + console.error('[BadDebtActions] GET summary error:', response?.status); return null; } @@ -381,26 +364,28 @@ export async function createBadDebt( data: Partial ): Promise<{ success: boolean; data?: BadDebtRecord; error?: string }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); console.log('[BadDebtActions] POST request:', apiData); - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts`, { method: 'POST', - headers, body: JSON.stringify(apiData), } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '악성채권 등록에 실패했습니다.', + error: result?.message || '악성채권 등록에 실패했습니다.', }; } @@ -427,26 +412,28 @@ export async function updateBadDebt( data: Partial ): Promise<{ success: boolean; data?: BadDebtRecord; error?: string }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); console.log('[BadDebtActions] PUT request:', apiData); - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}`, { method: 'PUT', - headers, body: JSON.stringify(apiData), } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '악성채권 수정에 실패했습니다.', + error: result?.message || '악성채권 수정에 실패했습니다.', }; } @@ -470,22 +457,21 @@ export async function updateBadDebt( */ export async function deleteBadDebt(id: string): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}`, - { - method: 'DELETE', - headers, - } + { method: 'DELETE' } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '악성채권 삭제에 실패했습니다.', + error: result?.message || '악성채권 삭제에 실패했습니다.', }; } @@ -506,22 +492,21 @@ export async function deleteBadDebt(id: string): Promise<{ success: boolean; err */ export async function toggleBadDebt(id: string): Promise<{ success: boolean; data?: BadDebtRecord; error?: string }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${id}/toggle`, - { - method: 'PATCH', - headers, - } + { method: 'PATCH' } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '상태 변경에 실패했습니다.', + error: result?.message || '상태 변경에 실패했습니다.', }; } @@ -548,23 +533,24 @@ export async function addBadDebtMemo( content: string ): Promise<{ success: boolean; data?: { id: string; content: string; createdAt: string; createdBy: string }; error?: string }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${badDebtId}/memos`, { method: 'POST', - headers, body: JSON.stringify({ content }), } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '메모 추가에 실패했습니다.', + error: result?.message || '메모 추가에 실패했습니다.', }; } @@ -595,22 +581,21 @@ export async function deleteBadDebtMemo( memoId: string ): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bad-debts/${badDebtId}/memos/${memoId}`, - { - method: 'DELETE', - headers, - } + { method: 'DELETE' } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '메모 삭제에 실패했습니다.', + error: result?.message || '메모 삭제에 실패했습니다.', }; } @@ -622,4 +607,4 @@ export async function deleteBadDebtMemo( error: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/BankTransactionInquiry/actions.ts b/src/components/accounting/BankTransactionInquiry/actions.ts index 3cccb50d..a805f1b8 100644 --- a/src/components/accounting/BankTransactionInquiry/actions.ts +++ b/src/components/accounting/BankTransactionInquiry/actions.ts @@ -1,21 +1,8 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { BankTransaction, TransactionKind } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface BankTransactionApiItem { id: number; @@ -101,7 +88,6 @@ export async function getBankTransactionList(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -117,19 +103,24 @@ export async function getBankTransactionList(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-transactions${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[BankTransactionActions] GET bank-transactions error:', response.status); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, + error: error.message, + }; + } + + if (!response?.ok) { + console.warn('[BankTransactionActions] GET bank-transactions error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, }; } @@ -189,7 +180,6 @@ export async function getBankTransactionSummary(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.startDate) searchParams.set('start_date', params.startDate); @@ -198,17 +188,17 @@ export async function getBankTransactionSummary(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-transactions/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[BankTransactionActions] GET summary error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[BankTransactionActions] GET summary error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -248,21 +238,20 @@ export async function getBankAccountOptions(): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-transactions/accounts`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[BankTransactionActions] GET accounts error:', response.status); + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + console.warn('[BankTransactionActions] GET accounts error:', response?.status); return { success: false, data: [], - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -288,4 +277,4 @@ export async function getBankAccountOptions(): Promise<{ error: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/BillManagement/actions.ts b/src/components/accounting/BillManagement/actions.ts index 06e5883d..029b0c41 100644 --- a/src/components/accounting/BillManagement/actions.ts +++ b/src/components/accounting/BillManagement/actions.ts @@ -1,23 +1,11 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { BillRecord, BillApiData, BillStatus } from './types'; import { transformApiToFrontend, transformFrontendToApi } from './types'; const API_URL = process.env.NEXT_PUBLIC_API_URL; -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== 어음 목록 조회 ===== export async function getBills(params: { search?: string; @@ -43,9 +31,9 @@ export async function getBills(params: { total: number; }; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); const queryParams = new URLSearchParams(); if (params.search) queryParams.append('search', params.search); @@ -62,11 +50,29 @@ export async function getBills(params: { if (params.perPage) queryParams.append('per_page', String(params.perPage)); if (params.page) queryParams.append('page', String(params.page)); - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills?${queryParams.toString()}`, - { method: 'GET', headers, cache: 'no-store' } + { method: 'GET' } ); + if (error?.__authError) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + __authError: true, + }; + } + + if (!response) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: error?.message || 'Failed to fetch bills', + }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -112,15 +118,22 @@ export async function getBill(id: string): Promise<{ success: boolean; data?: BillRecord; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills/${id}`, - { method: 'GET', headers, cache: 'no-store' } + { method: 'GET' } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to fetch bill' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -140,27 +153,32 @@ export async function getBill(id: string): Promise<{ // ===== 어음 등록 ===== export async function createBill( data: Partial -): Promise<{ success: boolean; data?: BillRecord; error?: string }> { +): Promise<{ success: boolean; data?: BillRecord; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); console.log('[createBill] Sending data:', JSON.stringify(apiData, null, 2)); - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills`, { method: 'POST', - headers, body: JSON.stringify(apiData), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to create bill' }; + } + const result = await response.json(); console.log('[createBill] Response:', result); if (!response.ok || !result.success) { - // 유효성 검사 에러 처리 if (result.errors) { const errorMessages = Object.values(result.errors).flat().join(', '); return { success: false, error: errorMessages || result.message || 'Failed to create bill' }; @@ -182,27 +200,32 @@ export async function createBill( export async function updateBill( id: string, data: Partial -): Promise<{ success: boolean; data?: BillRecord; error?: string }> { +): Promise<{ success: boolean; data?: BillRecord; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); console.log('[updateBill] Sending data:', JSON.stringify(apiData, null, 2)); - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills/${id}`, { method: 'PUT', - headers, body: JSON.stringify(apiData), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to update bill' }; + } + const result = await response.json(); console.log('[updateBill] Response:', result); if (!response.ok || !result.success) { - // 유효성 검사 에러 처리 if (result.errors) { const errorMessages = Object.values(result.errors).flat().join(', '); return { success: false, error: errorMessages || result.message || 'Failed to update bill' }; @@ -221,15 +244,21 @@ export async function updateBill( } // ===== 어음 삭제 ===== -export async function deleteBill(id: string): Promise<{ success: boolean; error?: string }> { +export async function deleteBill(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills/${id}`, - { method: 'DELETE', headers } + { method: 'DELETE' } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to delete bill' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -247,19 +276,24 @@ export async function deleteBill(id: string): Promise<{ success: boolean; error? export async function updateBillStatus( id: string, status: BillStatus -): Promise<{ success: boolean; data?: BillRecord; error?: string }> { +): Promise<{ success: boolean; data?: BillRecord; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills/${id}/status`, { method: 'PATCH', - headers, body: JSON.stringify({ status }), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to update bill status' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -293,9 +327,9 @@ export async function getBillSummary(params: { maturityAlertAmount: number; }; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); const queryParams = new URLSearchParams(); if (params.billType && params.billType !== 'all') queryParams.append('bill_type', params.billType); @@ -304,11 +338,19 @@ export async function getBillSummary(params: { if (params.maturityStartDate) queryParams.append('maturity_start_date', params.maturityStartDate); if (params.maturityEndDate) queryParams.append('maturity_end_date', params.maturityEndDate); - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/bills/summary?${queryParams.toString()}`, - { method: 'GET', headers, cache: 'no-store' } + { method: 'GET' } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to fetch summary' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -336,15 +378,22 @@ export async function getClients(): Promise<{ success: boolean; data?: { id: number; name: string }[]; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${API_URL}/api/v1/clients?per_page=100`, - { method: 'GET', headers, cache: 'no-store' } + { method: 'GET' } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || 'Failed to fetch clients' }; + } + const result = await response.json(); if (!response.ok || !result.success) { diff --git a/src/components/accounting/CardTransactionInquiry/actions.ts b/src/components/accounting/CardTransactionInquiry/actions.ts index cd2185f0..2d50c6da 100644 --- a/src/components/accounting/CardTransactionInquiry/actions.ts +++ b/src/components/accounting/CardTransactionInquiry/actions.ts @@ -1,21 +1,8 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { CardTransaction } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface CardTransactionApiItem { id: number; @@ -105,7 +92,6 @@ export async function getCardTransactionList(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -120,19 +106,24 @@ export async function getCardTransactionList(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[CardTransactionActions] GET card-transactions error:', response.status); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, + error: error.message, + }; + } + + if (!response?.ok) { + console.warn('[CardTransactionActions] GET card-transactions error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, }; } @@ -192,7 +183,6 @@ export async function getCardTransactionSummary(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.startDate) searchParams.set('start_date', params.startDate); @@ -201,17 +191,17 @@ export async function getCardTransactionSummary(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[CardTransactionActions] GET summary error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[CardTransactionActions] GET summary error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -254,20 +244,22 @@ export async function bulkUpdateAccountCode( error?: string; }> { try { - const headers = await getApiHeaders(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/card-transactions/bulk-update-account`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'PUT', - headers, body: JSON.stringify({ ids, account_code: accountCode }), }); - if (!response.ok) { - console.warn('[CardTransactionActions] PUT bulk-update-account error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[CardTransactionActions] PUT bulk-update-account error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -291,4 +283,4 @@ export async function bulkUpdateAccountCode( error: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/DailyReport/actions.ts b/src/components/accounting/DailyReport/actions.ts index 201b977c..39b40bd5 100644 --- a/src/components/accounting/DailyReport/actions.ts +++ b/src/components/accounting/DailyReport/actions.ts @@ -1,21 +1,9 @@ 'use server'; import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { NoteReceivableItem, DailyAccountItem, MatchStatus } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface NoteReceivableItemApi { id: string; @@ -89,7 +77,6 @@ export async function getNoteReceivables(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.date) searchParams.set('date', params.date); @@ -97,18 +84,18 @@ export async function getNoteReceivables(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/daily-report/note-receivables${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[DailyReportActions] GET note-receivables error:', response.status); + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + console.warn('[DailyReportActions] GET note-receivables error:', response?.status); return { success: false, data: [], - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -147,7 +134,6 @@ export async function getDailyAccounts(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.date) searchParams.set('date', params.date); @@ -155,18 +141,18 @@ export async function getDailyAccounts(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/daily-report/daily-accounts${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[DailyReportActions] GET daily-accounts error:', response.status); + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + console.warn('[DailyReportActions] GET daily-accounts error:', response?.status); return { success: false, data: [], - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -223,7 +209,6 @@ export async function getDailyReportSummary(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.date) searchParams.set('date', params.date); @@ -231,17 +216,17 @@ export async function getDailyReportSummary(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/daily-report/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[DailyReportActions] GET summary error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[DailyReportActions] GET summary error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -331,4 +316,4 @@ export async function exportDailyReportExcel(params?: { error: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/DepositManagement/actions.ts b/src/components/accounting/DepositManagement/actions.ts index 73b0605f..a686de11 100644 --- a/src/components/accounting/DepositManagement/actions.ts +++ b/src/components/accounting/DepositManagement/actions.ts @@ -1,21 +1,8 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { DepositRecord, DepositType, DepositStatus } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface DepositApiData { id: number; @@ -60,6 +47,23 @@ function transformApiToFrontend(apiData: DepositApiData): DepositRecord { }; } +// ===== Frontend → API 변환 ===== +function transformFrontendToApi(data: Partial): Record { + const result: Record = {}; + + if (data.depositDate !== undefined) result.deposit_date = data.depositDate; + if (data.depositAmount !== undefined) result.deposit_amount = data.depositAmount; + if (data.accountName !== undefined) result.account_name = data.accountName; + if (data.depositorName !== undefined) result.depositor_name = data.depositorName; + if (data.note !== undefined) result.note = data.note || null; + if (data.depositType !== undefined) result.deposit_type = data.depositType; + if (data.vendorId !== undefined) result.vendor_id = data.vendorId ? parseInt(data.vendorId, 10) : null; + if (data.vendorName !== undefined) result.vendor_name = data.vendorName || null; + if (data.status !== undefined) result.status = data.status; + + return result; +} + // ===== 입금 내역 조회 ===== export async function getDeposits(params?: { page?: number; @@ -80,122 +84,102 @@ export async function getDeposits(params?: { }; error?: string; }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.perPage) searchParams.set('per_page', String(params.perPage)); - if (params?.startDate) searchParams.set('start_date', params.startDate); - if (params?.endDate) searchParams.set('end_date', params.endDate); - if (params?.depositType && params.depositType !== 'all') { - searchParams.set('deposit_type', params.depositType); - } - if (params?.vendor && params.vendor !== 'all') { - searchParams.set('vendor', params.vendor); - } - if (params?.search) searchParams.set('search', params.search); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.perPage) searchParams.set('per_page', String(params.perPage)); + if (params?.startDate) searchParams.set('start_date', params.startDate); + if (params?.endDate) searchParams.set('end_date', params.endDate); + if (params?.depositType && params.depositType !== 'all') { + searchParams.set('deposit_type', params.depositType); + } + if (params?.vendor && params.vendor !== 'all') { + searchParams.set('vendor', params.vendor); + } + if (params?.search) searchParams.set('search', params.search); - const queryString = searchParams.toString(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits${queryString ? `?${queryString}` : ''}`; + const queryString = searchParams.toString(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[DepositActions] GET deposits error:', response.status); - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success) { - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: result.message || '입금 내역 조회에 실패했습니다.', - }; - } - - // API 응답 구조 처리: { data: { data: [...], current_page: ... } } 또는 { data: [...], meta: {...} } - const isPaginatedResponse = result.data && typeof result.data === 'object' && 'data' in result.data && Array.isArray(result.data.data); - const rawData = isPaginatedResponse ? result.data.data : (Array.isArray(result.data) ? result.data : []); - const deposits = rawData.map(transformApiToFrontend); - - const meta: PaginationMeta = isPaginatedResponse - ? { - current_page: result.data.current_page || 1, - last_page: result.data.last_page || 1, - per_page: result.data.per_page || 20, - total: result.data.total || deposits.length, - } - : result.meta || { - current_page: 1, - last_page: 1, - per_page: 20, - total: deposits.length, - }; - - return { - success: true, - data: deposits, - pagination: { - currentPage: meta.current_page, - lastPage: meta.last_page, - perPage: meta.per_page, - total: meta.total, - }, - }; - } catch (error) { - console.error('[DepositActions] getDeposits error:', error); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: '서버 오류가 발생했습니다.', + error: error.message, }; } + + if (!response?.ok) { + console.warn('[DepositActions] GET deposits error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: result.message || '입금 내역 조회에 실패했습니다.', + }; + } + + // API 응답 구조 처리: { data: { data: [...], current_page: ... } } 또는 { data: [...], meta: {...} } + const isPaginatedResponse = result.data && typeof result.data === 'object' && 'data' in result.data && Array.isArray(result.data.data); + const rawData = isPaginatedResponse ? result.data.data : (Array.isArray(result.data) ? result.data : []); + const deposits = rawData.map(transformApiToFrontend); + + const meta: PaginationMeta = isPaginatedResponse + ? { + current_page: result.data.current_page || 1, + last_page: result.data.last_page || 1, + per_page: result.data.per_page || 20, + total: result.data.total || deposits.length, + } + : result.meta || { + current_page: 1, + last_page: 1, + per_page: 20, + total: deposits.length, + }; + + return { + success: true, + data: deposits, + pagination: { + currentPage: meta.current_page, + lastPage: meta.last_page, + perPage: meta.per_page, + total: meta.total, + }, + }; } // ===== 입금 내역 삭제 ===== export async function deleteDeposit(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`, - { - method: 'DELETE', - headers, - } - ); - - const result = await response.json(); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입금 내역 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[DepositActions] deleteDeposit error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '입금 내역 삭제에 실패했습니다.' }; + } + + return { success: true }; } // ===== 계정과목명 일괄 저장 ===== @@ -203,38 +187,26 @@ export async function updateDepositTypes( ids: string[], depositType: string ): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/bulk-update-type`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify({ + ids: ids.map(id => parseInt(id, 10)), + deposit_type: depositType, + }), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/bulk-update-type`, - { - method: 'PUT', - headers, - body: JSON.stringify({ - ids: ids.map(id => parseInt(id, 10)), - deposit_type: depositType, - }), - } - ); - - const result = await response.json(); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '계정과목명 저장에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[DepositActions] updateDepositTypes error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '계정과목명 저장에 실패했습니다.' }; + } + + return { success: true }; } // ===== 입금 상세 조회 ===== @@ -243,105 +215,52 @@ export async function getDepositById(id: string): Promise<{ data?: DepositRecord; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[DepositActions] GET deposit error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '입금 내역 조회에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[DepositActions] getDepositById error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } -} -// ===== Frontend → API 변환 ===== -function transformFrontendToApi(data: Partial): Record { - const result: Record = {}; + if (!response?.ok) { + console.error('[DepositActions] GET deposit error:', response?.status); + return { success: false, error: `API 오류: ${response?.status}` }; + } - if (data.depositDate !== undefined) result.deposit_date = data.depositDate; - if (data.depositAmount !== undefined) result.deposit_amount = data.depositAmount; - if (data.accountName !== undefined) result.account_name = data.accountName; - if (data.depositorName !== undefined) result.depositor_name = data.depositorName; - if (data.note !== undefined) result.note = data.note || null; - if (data.depositType !== undefined) result.deposit_type = data.depositType; - if (data.vendorId !== undefined) result.vendor_id = data.vendorId ? parseInt(data.vendorId, 10) : null; - if (data.vendorName !== undefined) result.vendor_name = data.vendorName || null; - if (data.status !== undefined) result.status = data.status; + const result = await response.json(); - return result; + if (!result.success || !result.data) { + return { success: false, error: result.message || '입금 내역 조회에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 입금 등록 ===== export async function createDeposit( data: Partial ): Promise<{ success: boolean; data?: DepositRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[DepositActions] POST deposit request:', apiData); - console.log('[DepositActions] POST deposit request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[DepositActions] POST deposit response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입금 등록에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[DepositActions] createDeposit error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[DepositActions] POST deposit response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '입금 등록에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 입금 수정 ===== @@ -349,42 +268,27 @@ export async function updateDeposit( id: string, data: Partial ): Promise<{ success: boolean; data?: DepositRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[DepositActions] PUT deposit request:', apiData); - console.log('[DepositActions] PUT deposit request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[DepositActions] PUT deposit response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입금 수정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[DepositActions] updateDeposit error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[DepositActions] PUT deposit response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '입금 수정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 거래처 목록 조회 ===== @@ -393,39 +297,30 @@ export async function getVendors(): Promise<{ data: { id: string; name: string }[]; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - return { success: false, data: [], error: `API 오류: ${response.status}` }; - } - - const result = await response.json(); - - if (!result.success) { - return { success: false, data: [], error: result.message }; - } - - const clients = result.data?.data || result.data || []; - - return { - success: true, - data: clients.map((c: { id: number; name: string }) => ({ - id: String(c.id), - name: c.name, - })), - }; - } catch (error) { - console.error('[DepositActions] getVendors error:', error); - return { success: false, data: [], error: '서버 오류가 발생했습니다.' }; + if (error) { + return { success: false, data: [], error: error.message }; } -} \ No newline at end of file + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, data: [], error: result.message }; + } + + const clients = result.data?.data || result.data || []; + + return { + success: true, + data: clients.map((c: { id: number; name: string }) => ({ + id: String(c.id), + name: c.name, + })), + }; +} diff --git a/src/components/accounting/ExpectedExpenseManagement/actions.ts b/src/components/accounting/ExpectedExpenseManagement/actions.ts index 9618a4d1..568344f5 100644 --- a/src/components/accounting/ExpectedExpenseManagement/actions.ts +++ b/src/components/accounting/ExpectedExpenseManagement/actions.ts @@ -1,21 +1,8 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { ExpectedExpenseRecord, TransactionType, PaymentStatus, ApprovalStatus } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface ExpectedExpenseApiData { id: number; @@ -126,7 +113,6 @@ export async function getExpectedExpenses(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -150,19 +136,24 @@ export async function getExpectedExpenses(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[ExpectedExpenseActions] GET expected-expenses error:', response.status); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 50, total: 0 }, - error: `API 오류: ${response.status}`, + error: error.message, + }; + } + + if (!response?.ok) { + console.warn('[ExpectedExpenseActions] GET expected-expenses error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 50, total: 0 }, + error: `API 오류: ${response?.status}`, }; } @@ -214,22 +205,20 @@ export async function getExpectedExpenseById(id: string): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - console.error('[ExpectedExpenseActions] GET expected-expense error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.error('[ExpectedExpenseActions] GET expected-expense error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -260,24 +249,26 @@ export async function createExpectedExpense( data: Partial ): Promise<{ success: boolean; data?: ExpectedExpenseRecord; error?: string }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses`, { method: 'POST', - headers, body: JSON.stringify(apiData), } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '미지급비용 등록에 실패했습니다.', + error: result?.message || '미지급비용 등록에 실패했습니다.', }; } @@ -300,24 +291,26 @@ export async function updateExpectedExpense( data: Partial ): Promise<{ success: boolean; data?: ExpectedExpenseRecord; error?: string }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses/${id}`, { method: 'PUT', - headers, body: JSON.stringify(apiData), } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '미지급비용 수정에 실패했습니다.', + error: result?.message || '미지급비용 수정에 실패했습니다.', }; } @@ -337,22 +330,21 @@ export async function updateExpectedExpense( // ===== 미지급비용 삭제 ===== export async function deleteExpectedExpense(id: string): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses/${id}`, - { - method: 'DELETE', - headers, - } + { method: 'DELETE' } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '미지급비용 삭제에 실패했습니다.', + error: result?.message || '미지급비용 삭제에 실패했습니다.', }; } @@ -373,25 +365,26 @@ export async function deleteExpectedExpenses(ids: string[]): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses`, { method: 'DELETE', - headers, body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)), }), } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '미지급비용 일괄 삭제에 실패했습니다.', + error: result?.message || '미지급비용 일괄 삭제에 실패했습니다.', }; } @@ -418,13 +411,10 @@ export async function updateExpectedPaymentDate( error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses/update-payment-date`, { method: 'PUT', - headers, body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)), expected_payment_date: expectedPaymentDate, @@ -432,12 +422,16 @@ export async function updateExpectedPaymentDate( } ); - const result = await response.json(); + if (error) { + return { success: false, error: error.message }; + } - if (!response.ok || !result.success) { + const result = await response?.json(); + + if (!response?.ok || !result.success) { return { success: false, - error: result.message || '예상 지급일 변경에 실패했습니다.', + error: result?.message || '예상 지급일 변경에 실패했습니다.', }; } @@ -465,7 +459,6 @@ export async function getExpectedExpenseSummary(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.startDate) searchParams.set('start_date', params.startDate); @@ -477,17 +470,17 @@ export async function getExpectedExpenseSummary(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/expected-expenses/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[ExpectedExpenseActions] GET summary error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[ExpectedExpenseActions] GET summary error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -520,19 +513,17 @@ export async function getClients(): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - return { success: false, data: [], error: `API 오류: ${response.status}` }; + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; } const result = await response.json(); @@ -563,19 +554,17 @@ export async function getBankAccounts(): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?per_page=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - return { success: false, data: [], error: `API 오류: ${response.status}` }; + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; } const result = await response.json(); @@ -599,4 +588,4 @@ export async function getBankAccounts(): Promise<{ console.error('[ExpectedExpenseActions] getBankAccounts error:', error); return { success: false, data: [], error: '서버 오류가 발생했습니다.' }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/PurchaseManagement/actions.ts b/src/components/accounting/PurchaseManagement/actions.ts index e0dc0247..a7854c03 100644 --- a/src/components/accounting/PurchaseManagement/actions.ts +++ b/src/components/accounting/PurchaseManagement/actions.ts @@ -12,7 +12,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { PurchaseRecord, PurchaseType } from './types'; // ===== API 데이터 타입 ===== @@ -92,19 +92,6 @@ function transformFrontendToApi(data: Partial): Record { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== 페이지네이션 타입 ===== interface PaginationMeta { currentPage: number; @@ -128,81 +115,75 @@ export async function getPurchases(params?: { pagination: PaginationMeta; error?: string; }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.perPage) searchParams.set('per_page', String(params.perPage)); - if (params?.startDate) searchParams.set('start_date', params.startDate); - if (params?.endDate) searchParams.set('end_date', params.endDate); - if (params?.clientId) searchParams.set('client_id', params.clientId); - if (params?.status && params.status !== 'all') { - searchParams.set('status', params.status); - } - if (params?.search) searchParams.set('search', params.search); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.perPage) searchParams.set('per_page', String(params.perPage)); + if (params?.startDate) searchParams.set('start_date', params.startDate); + if (params?.endDate) searchParams.set('end_date', params.endDate); + if (params?.clientId) searchParams.set('client_id', params.clientId); + if (params?.status && params.status !== 'all') { + searchParams.set('status', params.status); + } + if (params?.search) searchParams.set('search', params.search); - const queryString = searchParams.toString(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases${queryString ? `?${queryString}` : ''}`; + const queryString = searchParams.toString(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases${queryString ? `?${queryString}` : ''}`; - console.log('[PurchaseActions] GET purchases:', url); + console.log('[PurchaseActions] GET purchases:', url); - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[PurchaseActions] GET purchases error:', response.status); - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success) { - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: result.message || '매입 목록 조회에 실패했습니다.', - }; - } - - const paginatedData: PurchaseApiPaginatedResponse = result.data || { - data: [], - current_page: 1, - last_page: 1, - per_page: 20, - total: 0, - }; - - const purchases = (paginatedData.data || []).map(transformApiToFrontend); - - return { - success: true, - data: purchases, - pagination: { - currentPage: paginatedData.current_page, - lastPage: paginatedData.last_page, - perPage: paginatedData.per_page, - total: paginatedData.total, - }, - }; - } catch (error) { - console.error('[PurchaseActions] getPurchases error:', error); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: '서버 오류가 발생했습니다.', + error: error.message, }; } + + if (!response?.ok) { + console.warn('[PurchaseActions] GET purchases error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: result.message || '매입 목록 조회에 실패했습니다.', + }; + } + + const paginatedData: PurchaseApiPaginatedResponse = result.data || { + data: [], + current_page: 1, + last_page: 1, + per_page: 20, + total: 0, + }; + + const purchases = (paginatedData.data || []).map(transformApiToFrontend); + + return { + success: true, + data: purchases, + pagination: { + currentPage: paginatedData.current_page, + lastPage: paginatedData.last_page, + perPage: paginatedData.per_page, + total: paginatedData.total, + }, + }; } // ===== 매입 상세 조회 ===== @@ -211,88 +192,52 @@ export async function getPurchaseById(id: string): Promise<{ data?: PurchaseRecord; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[PurchaseActions] GET purchase error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '매입 조회에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[PurchaseActions] getPurchaseById error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + if (!response?.ok) { + console.error('[PurchaseActions] GET purchase error:', response?.status); + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success || !result.data) { + return { success: false, error: result.message || '매입 조회에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 매입 등록 ===== export async function createPurchase( data: Partial ): Promise<{ success: boolean; data?: PurchaseRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[PurchaseActions] POST purchase request:', apiData); - console.log('[PurchaseActions] POST purchase request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[PurchaseActions] POST purchase response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매입 등록에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[PurchaseActions] createPurchase error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[PurchaseActions] POST purchase response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매입 등록에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 매입 수정 ===== @@ -300,42 +245,27 @@ export async function updatePurchase( id: string, data: Partial ): Promise<{ success: boolean; data?: PurchaseRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[PurchaseActions] PUT purchase request:', apiData); - console.log('[PurchaseActions] PUT purchase request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[PurchaseActions] PUT purchase response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매입 수정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[PurchaseActions] updatePurchase error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[PurchaseActions] PUT purchase response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매입 수정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 세금계산서 수취 상태 토글 ===== @@ -348,35 +278,21 @@ export async function togglePurchaseTaxInvoice( // ===== 매입 삭제 ===== export async function deletePurchase(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`, - { - method: 'DELETE', - headers, - } - ); - - const result = await response.json(); - console.log('[PurchaseActions] DELETE purchase response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매입 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[PurchaseActions] deletePurchase error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[PurchaseActions] DELETE purchase response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매입 삭제에 실패했습니다.' }; + } + + return { success: true }; } // ===== 매입 확정 ===== @@ -385,38 +301,21 @@ export async function confirmPurchase(id: string): Promise<{ data?: PurchaseRecord; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}/confirm`; + const { response, error } = await serverFetch(url, { method: 'PUT' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}/confirm`, - { - method: 'PUT', - headers, - } - ); - - const result = await response.json(); - console.log('[PurchaseActions] PUT confirm response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매입 확정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[PurchaseActions] confirmPurchase error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[PurchaseActions] PUT confirm response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매입 확정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 은행 계좌 목록 조회 ===== @@ -425,43 +324,34 @@ export async function getBankAccounts(): Promise<{ data: { id: string; bankName: string; accountName: string; accountNumber: string }[]; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?per_page=100`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?per_page=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - return { success: false, data: [], error: `API 오류: ${response.status}` }; - } - - const result = await response.json(); - - if (!result.success) { - return { success: false, data: [], error: result.message }; - } - - const accounts = result.data?.data || result.data || []; - - return { - success: true, - data: accounts.map((a: { id: number; bank_name: string; account_name: string; account_number: string }) => ({ - id: String(a.id), - bankName: a.bank_name, - accountName: a.account_name, - accountNumber: a.account_number, - })), - }; - } catch (error) { - console.error('[PurchaseActions] getBankAccounts error:', error); - return { success: false, data: [], error: '서버 오류가 발생했습니다.' }; + if (error) { + return { success: false, data: [], error: error.message }; } + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, data: [], error: result.message }; + } + + const accounts = result.data?.data || result.data || []; + + return { + success: true, + data: accounts.map((a: { id: number; bank_name: string; account_name: string; account_number: string }) => ({ + id: String(a.id), + bankName: a.bank_name, + accountName: a.account_name, + accountNumber: a.account_number, + })), + }; } // ===== 거래처 목록 조회 ===== @@ -470,39 +360,30 @@ export async function getVendors(): Promise<{ data: { id: string; name: string }[]; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - return { success: false, data: [], error: `API 오류: ${response.status}` }; - } - - const result = await response.json(); - - if (!result.success) { - return { success: false, data: [], error: result.message }; - } - - const clients = result.data?.data || result.data || []; - - return { - success: true, - data: clients.map((c: { id: number; name: string }) => ({ - id: String(c.id), - name: c.name, - })), - }; - } catch (error) { - console.error('[PurchaseActions] getVendors error:', error); - return { success: false, data: [], error: '서버 오류가 발생했습니다.' }; + if (error) { + return { success: false, data: [], error: error.message }; } + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, data: [], error: result.message }; + } + + const clients = result.data?.data || result.data || []; + + return { + success: true, + data: clients.map((c: { id: number; name: string }) => ({ + id: String(c.id), + name: c.name, + })), + }; } \ No newline at end of file diff --git a/src/components/accounting/ReceivablesStatus/actions.ts b/src/components/accounting/ReceivablesStatus/actions.ts index 1f3e8ca6..1a89c4d6 100644 --- a/src/components/accounting/ReceivablesStatus/actions.ts +++ b/src/components/accounting/ReceivablesStatus/actions.ts @@ -1,21 +1,9 @@ 'use server'; import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { VendorReceivables, CategoryType, MonthlyAmount } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface CategoryAmountApi { category: CategoryType; @@ -65,7 +53,6 @@ export async function getReceivablesList(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.year) searchParams.set('year', String(params.year)); @@ -77,18 +64,18 @@ export async function getReceivablesList(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivables${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[ReceivablesActions] GET receivables error:', response.status); + if (error) { + return { success: false, data: [], error: error.message }; + } + + if (!response?.ok) { + console.warn('[ReceivablesActions] GET receivables error:', response?.status); return { success: false, data: [], - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -134,7 +121,6 @@ export async function getReceivablesSummary(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.year) searchParams.set('year', String(params.year)); @@ -142,17 +128,17 @@ export async function getReceivablesSummary(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivables/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[ReceivablesActions] GET summary error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[ReceivablesActions] GET summary error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -196,12 +182,10 @@ export async function updateOverdueStatus( error?: string; }> { try { - const headers = await getApiHeaders(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivables/overdue-status`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'PUT', - headers, body: JSON.stringify({ updates: updates.map(item => ({ id: item.id, @@ -210,11 +194,15 @@ export async function updateOverdueStatus( }), }); - if (!response.ok) { - console.warn('[ReceivablesActions] PUT overdue-status error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[ReceivablesActions] PUT overdue-status error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -296,4 +284,4 @@ export async function exportReceivablesExcel(params?: { error: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/SalesManagement/actions.ts b/src/components/accounting/SalesManagement/actions.ts index 8477e6bb..e0ce9560 100644 --- a/src/components/accounting/SalesManagement/actions.ts +++ b/src/components/accounting/SalesManagement/actions.ts @@ -13,7 +13,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { SalesRecord, SaleApiData, @@ -21,19 +21,6 @@ import type { } from './types'; import { transformApiToFrontend, transformFrontendToApi } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== 페이지네이션 타입 ===== interface PaginationMeta { currentPage: number; @@ -57,82 +44,76 @@ export async function getSales(params?: { pagination: PaginationMeta; error?: string; }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.perPage) searchParams.set('per_page', String(params.perPage)); - if (params?.startDate) searchParams.set('start_date', params.startDate); - if (params?.endDate) searchParams.set('end_date', params.endDate); - if (params?.clientId) searchParams.set('client_id', params.clientId); - if (params?.status && params.status !== 'all') { - searchParams.set('status', params.status); - } - if (params?.search) searchParams.set('search', params.search); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.perPage) searchParams.set('per_page', String(params.perPage)); + if (params?.startDate) searchParams.set('start_date', params.startDate); + if (params?.endDate) searchParams.set('end_date', params.endDate); + if (params?.clientId) searchParams.set('client_id', params.clientId); + if (params?.status && params.status !== 'all') { + searchParams.set('status', params.status); + } + if (params?.search) searchParams.set('search', params.search); - const queryString = searchParams.toString(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales${queryString ? `?${queryString}` : ''}`; + const queryString = searchParams.toString(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales${queryString ? `?${queryString}` : ''}`; - console.log('[SalesActions] GET sales:', url); + console.log('[SalesActions] GET sales:', url); - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[SalesActions] GET sales error:', response.status); - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success) { - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: result.message || '매출 목록 조회에 실패했습니다.', - }; - } - - // API 응답 구조: { success, data: { data: [], current_page, last_page, per_page, total } } - const paginatedData: SaleApiPaginatedResponse = result.data || { - data: [], - current_page: 1, - last_page: 1, - per_page: 20, - total: 0, - }; - - const sales = (paginatedData.data || []).map(transformApiToFrontend); - - return { - success: true, - data: sales, - pagination: { - currentPage: paginatedData.current_page, - lastPage: paginatedData.last_page, - perPage: paginatedData.per_page, - total: paginatedData.total, - }, - }; - } catch (error) { - console.error('[SalesActions] getSales error:', error); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: '서버 오류가 발생했습니다.', + error: error.message, }; } + + if (!response?.ok) { + console.warn('[SalesActions] GET sales error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: result.message || '매출 목록 조회에 실패했습니다.', + }; + } + + // API 응답 구조: { success, data: { data: [], current_page, last_page, per_page, total } } + const paginatedData: SaleApiPaginatedResponse = result.data || { + data: [], + current_page: 1, + last_page: 1, + per_page: 20, + total: 0, + }; + + const sales = (paginatedData.data || []).map(transformApiToFrontend); + + return { + success: true, + data: sales, + pagination: { + currentPage: paginatedData.current_page, + lastPage: paginatedData.last_page, + perPage: paginatedData.per_page, + total: paginatedData.total, + }, + }; } // ===== 매출 상세 조회 ===== @@ -141,88 +122,52 @@ export async function getSaleById(id: string): Promise<{ data?: SalesRecord; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[SalesActions] GET sale error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '매출 조회에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[SalesActions] getSaleById error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + if (!response?.ok) { + console.error('[SalesActions] GET sale error:', response?.status); + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success || !result.data) { + return { success: false, error: result.message || '매출 조회에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 매출 등록 ===== export async function createSale( data: Partial ): Promise<{ success: boolean; data?: SalesRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[SalesActions] POST sale request:', apiData); - console.log('[SalesActions] POST sale request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[SalesActions] POST sale response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매출 등록에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[SalesActions] createSale error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[SalesActions] POST sale response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매출 등록에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 매출 수정 ===== @@ -230,42 +175,27 @@ export async function updateSale( id: string, data: Partial ): Promise<{ success: boolean; data?: SalesRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[SalesActions] PUT sale request:', apiData); - console.log('[SalesActions] PUT sale request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[SalesActions] PUT sale response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매출 수정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[SalesActions] updateSale error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[SalesActions] PUT sale response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매출 수정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 세금계산서/거래명세서 발행 상태 토글 ===== @@ -284,35 +214,21 @@ export async function toggleSaleIssuance( // ===== 매출 삭제 ===== export async function deleteSale(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`, - { - method: 'DELETE', - headers, - } - ); - - const result = await response.json(); - console.log('[SalesActions] DELETE sale response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매출 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[SalesActions] deleteSale error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[SalesActions] DELETE sale response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매출 삭제에 실패했습니다.' }; + } + + return { success: true }; } // ===== 매출 확정 ===== @@ -321,38 +237,21 @@ export async function confirmSale(id: string): Promise<{ data?: SalesRecord; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}/confirm`; + const { response, error } = await serverFetch(url, { method: 'PUT' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}/confirm`, - { - method: 'PUT', - headers, - } - ); - - const result = await response.json(); - console.log('[SalesActions] PUT confirm response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '매출 확정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[SalesActions] confirmSale error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[SalesActions] PUT confirm response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '매출 확정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 매출 요약 통계 ===== @@ -371,58 +270,43 @@ export async function getSalesSummary(params?: { }; error?: string; }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.startDate) searchParams.set('start_date', params.startDate); - if (params?.endDate) searchParams.set('end_date', params.endDate); + if (params?.startDate) searchParams.set('start_date', params.startDate); + if (params?.endDate) searchParams.set('end_date', params.endDate); - const queryString = searchParams.toString(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/summary${queryString ? `?${queryString}` : ''}`; + const queryString = searchParams.toString(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[SalesActions] GET summary error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success) { - return { - success: false, - error: result.message || '매출 요약 조회에 실패했습니다.', - }; - } - - // API 응답 → 프론트엔드 형식 변환 - const summary = result.data || {}; - - return { - success: true, - data: { - totalAmount: parseFloat(summary.total_amount) || 0, - totalCount: summary.total_count || 0, - confirmedAmount: parseFloat(summary.confirmed_amount) || 0, - confirmedCount: summary.confirmed_count || 0, - draftAmount: parseFloat(summary.draft_amount) || 0, - draftCount: summary.draft_count || 0, - }, - }; - } catch (error) { - console.error('[SalesActions] getSalesSummary error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + if (!response?.ok) { + console.warn('[SalesActions] GET summary error:', response?.status); + return { success: false, error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '매출 요약 조회에 실패했습니다.' }; + } + + // API 응답 → 프론트엔드 형식 변환 + const summary = result.data || {}; + + return { + success: true, + data: { + totalAmount: parseFloat(summary.total_amount) || 0, + totalCount: summary.total_count || 0, + confirmedAmount: parseFloat(summary.confirmed_amount) || 0, + confirmedCount: summary.confirmed_count || 0, + draftAmount: parseFloat(summary.draft_amount) || 0, + draftCount: summary.draft_count || 0, + }, + }; } \ No newline at end of file diff --git a/src/components/accounting/VendorLedger/actions.ts b/src/components/accounting/VendorLedger/actions.ts index 8e136476..f18e49ac 100644 --- a/src/components/accounting/VendorLedger/actions.ts +++ b/src/components/accounting/VendorLedger/actions.ts @@ -1,21 +1,9 @@ 'use server'; import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { VendorLedgerItem, VendorLedgerDetail, VendorLedgerSummary, TransactionEntry } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface VendorLedgerApiItem { id: number; @@ -171,7 +159,6 @@ export async function getVendorLedgerList(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -185,19 +172,24 @@ export async function getVendorLedgerList(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/vendor-ledger${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[VendorLedgerActions] GET vendor-ledger error:', response.status); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, + error: error.message, + }; + } + + if (!response?.ok) { + console.warn('[VendorLedgerActions] GET vendor-ledger error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, }; } @@ -252,7 +244,6 @@ export async function getVendorLedgerSummary(params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.startDate) searchParams.set('start_date', params.startDate); @@ -261,17 +252,17 @@ export async function getVendorLedgerSummary(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/vendor-ledger/summary${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[VendorLedgerActions] GET summary error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[VendorLedgerActions] GET summary error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -315,7 +306,6 @@ export async function getVendorLedgerDetail(clientId: string, params?: { error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.startDate) searchParams.set('start_date', params.startDate); @@ -324,17 +314,17 @@ export async function getVendorLedgerDetail(clientId: string, params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/vendor-ledger/${clientId}${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[VendorLedgerActions] GET detail error:', response.status); + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.error('[VendorLedgerActions] GET detail error:', response?.status); return { success: false, - error: `API 오류: ${response.status}`, + error: `API 오류: ${response?.status}`, }; } @@ -484,4 +474,4 @@ export async function exportVendorLedgerDetailPdf(clientId: string, params?: { error: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/accounting/VendorManagement/actions.ts b/src/components/accounting/VendorManagement/actions.ts index b124e169..fcf76534 100644 --- a/src/components/accounting/VendorManagement/actions.ts +++ b/src/components/accounting/VendorManagement/actions.ts @@ -12,7 +12,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { Vendor, ClientApiData, @@ -24,21 +24,6 @@ import type { BadDebtStatus, } from './types'; -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 데이터 → 프론트엔드 타입 변환 */ @@ -166,82 +151,59 @@ export async function getClients(params?: { q?: string; only_active?: boolean; }): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.size) searchParams.set('size', String(params.size)); - if (params?.q) searchParams.set('q', params.q); - if (params?.only_active !== undefined) searchParams.set('only_active', String(params.only_active)); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.size) searchParams.set('size', String(params.size)); + if (params?.q) searchParams.set('q', params.q); + if (params?.only_active !== undefined) searchParams.set('only_active', String(params.only_active)); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?${searchParams.toString()}`; - console.log('[VendorActions] GET clients:', url); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?${searchParams.toString()}`; + console.log('[VendorActions] GET clients:', url); - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[VendorActions] GET clients error:', response.status); - return { success: false, data: [], total: 0, error: `HTTP ${response.status}` }; - } - - const result: ApiResponse> = await response.json(); - console.log('[VendorActions] GET clients response:', result.success, result.data?.total); - - if (!result.success || !result.data) { - return { success: false, data: [], total: 0, error: result.message }; - } - - const vendors = result.data.data.map(transformApiToFrontend); - - return { - success: true, - data: vendors, - total: result.data.total, - }; - } catch (error) { - console.error('[VendorActions] getClients error:', error); - return { success: false, data: [], total: 0, error: '서버 오류가 발생했습니다.' }; + if (error) { + return { success: false, data: [], total: 0, error: error.message }; } + + if (!response?.ok) { + console.error('[VendorActions] GET clients error:', response?.status); + return { success: false, data: [], total: 0, error: `HTTP ${response?.status}` }; + } + + const result: ApiResponse> = await response.json(); + console.log('[VendorActions] GET clients response:', result.success, result.data?.total); + + if (!result.success || !result.data) { + return { success: false, data: [], total: 0, error: result.message }; + } + + const vendors = result.data.data.map(transformApiToFrontend); + + return { success: true, data: vendors, total: result.data.total }; } /** * 거래처 상세 조회 */ export async function getClientById(id: string): Promise { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[VendorActions] GET client error:', response.status); - return null; - } - - const result: ApiResponse = await response.json(); - console.log('[VendorActions] GET client response:', result.success); - - if (!result.success || !result.data) { - return null; - } - - return transformApiToFrontend(result.data); - } catch (error) { - console.error('[VendorActions] getClientById error:', error); + if (error || !response?.ok) { + console.error('[VendorActions] GET client error:', error?.message || response?.status); return null; } + + const result: ApiResponse = await response.json(); + console.log('[VendorActions] GET client response:', result.success); + + if (!result.success || !result.data) { + return null; + } + + return transformApiToFrontend(result.data); } /** @@ -250,42 +212,27 @@ export async function getClientById(id: string): Promise { export async function createClient( data: Partial ): Promise<{ success: boolean; data?: Vendor; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[VendorActions] POST client request:', apiData); - console.log('[VendorActions] POST client request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[VendorActions] POST client response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '거래처 등록에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[VendorActions] createClient error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[VendorActions] POST client response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '거래처 등록에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } /** @@ -295,113 +242,67 @@ export async function updateClient( id: string, data: Partial ): Promise<{ success: boolean; data?: Vendor; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[VendorActions] PUT client request:', apiData); - console.log('[VendorActions] PUT client request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[VendorActions] PUT client response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '거래처 수정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[VendorActions] updateClient error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[VendorActions] PUT client response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '거래처 수정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } /** * 거래처 삭제 */ export async function deleteClient(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`, - { - method: 'DELETE', - headers, - } - ); - - const result = await response.json(); - console.log('[VendorActions] DELETE client response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '거래처 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[VendorActions] deleteClient error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[VendorActions] DELETE client response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '거래처 삭제에 실패했습니다.' }; + } + + return { success: true }; } /** * 거래처 활성/비활성 토글 */ export async function toggleClientActive(id: string): Promise<{ success: boolean; data?: Vendor; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}/toggle`; + const { response, error } = await serverFetch(url, { method: 'PATCH' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}/toggle`, - { - method: 'PATCH', - headers, - } - ); - - const result = await response.json(); - console.log('[VendorActions] PATCH toggle response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '상태 변경에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[VendorActions] toggleClientActive error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[VendorActions] PATCH toggle response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '상태 변경에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } diff --git a/src/components/accounting/WithdrawalManagement/actions.ts b/src/components/accounting/WithdrawalManagement/actions.ts index 13cd1689..6924fb35 100644 --- a/src/components/accounting/WithdrawalManagement/actions.ts +++ b/src/components/accounting/WithdrawalManagement/actions.ts @@ -1,21 +1,8 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { WithdrawalRecord, WithdrawalType } from './types'; -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== API 응답 타입 ===== interface WithdrawalApiData { id: number; @@ -58,6 +45,22 @@ function transformApiToFrontend(apiData: WithdrawalApiData): WithdrawalRecord { }; } +// ===== Frontend → API 변환 ===== +function transformFrontendToApi(data: Partial): Record { + const result: Record = {}; + + if (data.withdrawalDate !== undefined) result.withdrawal_date = data.withdrawalDate; + if (data.withdrawalAmount !== undefined) result.withdrawal_amount = data.withdrawalAmount; + if (data.accountName !== undefined) result.account_name = data.accountName; + if (data.recipientName !== undefined) result.recipient_name = data.recipientName; + if (data.note !== undefined) result.note = data.note || null; + if (data.withdrawalType !== undefined) result.withdrawal_type = data.withdrawalType; + if (data.vendorId !== undefined) result.vendor_id = data.vendorId ? parseInt(data.vendorId, 10) : null; + if (data.vendorName !== undefined) result.vendor_name = data.vendorName || null; + + return result; +} + // ===== 출금 내역 조회 ===== export async function getWithdrawals(params?: { page?: number; @@ -78,111 +81,91 @@ export async function getWithdrawals(params?: { }; error?: string; }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.perPage) searchParams.set('per_page', String(params.perPage)); - if (params?.startDate) searchParams.set('start_date', params.startDate); - if (params?.endDate) searchParams.set('end_date', params.endDate); - if (params?.withdrawalType && params.withdrawalType !== 'all') { - searchParams.set('withdrawal_type', params.withdrawalType); - } - if (params?.vendor && params.vendor !== 'all') { - searchParams.set('vendor', params.vendor); - } - if (params?.search) searchParams.set('search', params.search); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.perPage) searchParams.set('per_page', String(params.perPage)); + if (params?.startDate) searchParams.set('start_date', params.startDate); + if (params?.endDate) searchParams.set('end_date', params.endDate); + if (params?.withdrawalType && params.withdrawalType !== 'all') { + searchParams.set('withdrawal_type', params.withdrawalType); + } + if (params?.vendor && params.vendor !== 'all') { + searchParams.set('vendor', params.vendor); + } + if (params?.search) searchParams.set('search', params.search); - const queryString = searchParams.toString(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals${queryString ? `?${queryString}` : ''}`; + const queryString = searchParams.toString(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.warn('[WithdrawalActions] GET withdrawals error:', response.status); - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success) { - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: result.message || '출금 내역 조회에 실패했습니다.', - }; - } - - const withdrawals = (result.data || []).map(transformApiToFrontend); - const meta: PaginationMeta = result.meta || { - current_page: 1, - last_page: 1, - per_page: 20, - total: withdrawals.length, - }; - - return { - success: true, - data: withdrawals, - pagination: { - currentPage: meta.current_page, - lastPage: meta.last_page, - perPage: meta.per_page, - total: meta.total, - }, - }; - } catch (error) { - console.error('[WithdrawalActions] getWithdrawals error:', error); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: '서버 오류가 발생했습니다.', + error: error.message, }; } + + if (!response?.ok) { + console.warn('[WithdrawalActions] GET withdrawals error:', response?.status); + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: `API 오류: ${response?.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: result.message || '출금 내역 조회에 실패했습니다.', + }; + } + + const withdrawals = (result.data || []).map(transformApiToFrontend); + const meta: PaginationMeta = result.meta || { + current_page: 1, + last_page: 1, + per_page: 20, + total: withdrawals.length, + }; + + return { + success: true, + data: withdrawals, + pagination: { + currentPage: meta.current_page, + lastPage: meta.last_page, + perPage: meta.per_page, + total: meta.total, + }, + }; } // ===== 출금 내역 삭제 ===== export async function deleteWithdrawal(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`, - { - method: 'DELETE', - headers, - } - ); - - const result = await response.json(); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '출금 내역 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[WithdrawalActions] deleteWithdrawal error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '출금 내역 삭제에 실패했습니다.' }; + } + + return { success: true }; } // ===== 계정과목명 일괄 저장 ===== @@ -190,38 +173,26 @@ export async function updateWithdrawalTypes( ids: string[], withdrawalType: string ): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/bulk-update-type`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify({ + ids: ids.map(id => parseInt(id, 10)), + withdrawal_type: withdrawalType, + }), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/bulk-update-type`, - { - method: 'PUT', - headers, - body: JSON.stringify({ - ids: ids.map(id => parseInt(id, 10)), - withdrawal_type: withdrawalType, - }), - } - ); - - const result = await response.json(); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '계정과목명 저장에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[WithdrawalActions] updateWithdrawalTypes error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '계정과목명 저장에 실패했습니다.' }; + } + + return { success: true }; } // ===== 출금 상세 조회 ===== @@ -230,104 +201,52 @@ export async function getWithdrawalById(id: string): Promise<{ data?: WithdrawalRecord; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[WithdrawalActions] GET withdrawal error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; - } - - const result = await response.json(); - - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '출금 내역 조회에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[WithdrawalActions] getWithdrawalById error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } -} -// ===== Frontend → API 변환 ===== -function transformFrontendToApi(data: Partial): Record { - const result: Record = {}; + if (!response?.ok) { + console.error('[WithdrawalActions] GET withdrawal error:', response?.status); + return { success: false, error: `API 오류: ${response?.status}` }; + } - if (data.withdrawalDate !== undefined) result.withdrawal_date = data.withdrawalDate; - if (data.withdrawalAmount !== undefined) result.withdrawal_amount = data.withdrawalAmount; - if (data.accountName !== undefined) result.account_name = data.accountName; - if (data.recipientName !== undefined) result.recipient_name = data.recipientName; - if (data.note !== undefined) result.note = data.note || null; - if (data.withdrawalType !== undefined) result.withdrawal_type = data.withdrawalType; - if (data.vendorId !== undefined) result.vendor_id = data.vendorId ? parseInt(data.vendorId, 10) : null; - if (data.vendorName !== undefined) result.vendor_name = data.vendorName || null; + const result = await response.json(); - return result; + if (!result.success || !result.data) { + return { success: false, error: result.message || '출금 내역 조회에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 출금 등록 ===== export async function createWithdrawal( data: Partial ): Promise<{ success: boolean; data?: WithdrawalRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[WithdrawalActions] POST withdrawal request:', apiData); - console.log('[WithdrawalActions] POST withdrawal request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[WithdrawalActions] POST withdrawal response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '출금 등록에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[WithdrawalActions] createWithdrawal error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[WithdrawalActions] POST withdrawal response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '출금 등록에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 출금 수정 ===== @@ -335,42 +254,27 @@ export async function updateWithdrawal( id: string, data: Partial ): Promise<{ success: boolean; data?: WithdrawalRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[WithdrawalActions] PUT withdrawal request:', apiData); - console.log('[WithdrawalActions] PUT withdrawal request:', apiData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`; + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } - ); - - const result = await response.json(); - console.log('[WithdrawalActions] PUT withdrawal response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '출금 수정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[WithdrawalActions] updateWithdrawal error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + if (error) { + return { success: false, error: error.message }; } + + const result = await response?.json(); + console.log('[WithdrawalActions] PUT withdrawal response:', result); + + if (!response?.ok || !result.success) { + return { success: false, error: result?.message || '출금 수정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToFrontend(result.data) }; } // ===== 거래처 목록 조회 ===== @@ -379,39 +283,30 @@ export async function getVendors(): Promise<{ data: { id: string; name: string }[]; error?: string; }> { - try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - return { success: false, data: [], error: `API 오류: ${response.status}` }; - } - - const result = await response.json(); - - if (!result.success) { - return { success: false, data: [], error: result.message }; - } - - const clients = result.data?.data || result.data || []; - - return { - success: true, - data: clients.map((c: { id: number; name: string }) => ({ - id: String(c.id), - name: c.name, - })), - }; - } catch (error) { - console.error('[WithdrawalActions] getVendors error:', error); - return { success: false, data: [], error: '서버 오류가 발생했습니다.' }; + if (error) { + return { success: false, data: [], error: error.message }; } -} \ No newline at end of file + + if (!response?.ok) { + return { success: false, data: [], error: `API 오류: ${response?.status}` }; + } + + const result = await response.json(); + + if (!result.success) { + return { success: false, data: [], error: result.message }; + } + + const clients = result.data?.data || result.data || []; + + return { + success: true, + data: clients.map((c: { id: number; name: string }) => ({ + id: String(c.id), + name: c.name, + })), + }; +} diff --git a/src/components/approval/ApprovalBox/actions.ts b/src/components/approval/ApprovalBox/actions.ts index 426cd0c9..b1eb5997 100644 --- a/src/components/approval/ApprovalBox/actions.ts +++ b/src/components/approval/ApprovalBox/actions.ts @@ -10,7 +10,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types'; // ============================================ @@ -81,21 +81,6 @@ interface InboxStepApiData { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 상태 → 프론트엔드 상태 변환 */ @@ -108,6 +93,22 @@ function mapApiStatus(apiStatus: string): ApprovalStatus { return statusMap[apiStatus] || 'pending'; } +/** + * 프론트엔드 탭 상태 → 백엔드 API 상태 변환 + * 백엔드 inbox API가 기대하는 값: + * - requested: 결재 요청 (현재 내 차례) = 미결재 + * - completed: 내가 처리 완료 = 결재완료 + * - rejected: 내가 반려한 문서 = 결재반려 + */ +function mapTabToApiStatus(tabStatus: string): string | undefined { + const statusMap: Record = { + 'pending': 'requested', // 미결재 → 결재 요청 + 'approved': 'completed', // 결재완료 → 처리 완료 + 'rejected': 'rejected', // 반려 (동일) + }; + return statusMap[tabStatus]; +} + /** * 양식 카테고리 → 결재 유형 변환 */ @@ -175,16 +176,19 @@ export async function getInbox(params?: { approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc'; -}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number }> { +}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); if (params?.per_page) searchParams.set('per_page', String(params.per_page)); if (params?.search) searchParams.set('search', params.search); if (params?.status && params.status !== 'all') { - searchParams.set('status', params.status); + // 프론트엔드 탭 상태를 백엔드 API 상태로 변환 + const apiStatus = mapTabToApiStatus(params.status); + if (apiStatus) { + searchParams.set('status', apiStatus); + } } if (params?.approval_type && params.approval_type !== 'all') { searchParams.set('approval_type', params.approval_type); @@ -194,20 +198,20 @@ export async function getInbox(params?: { const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/inbox?${searchParams.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[ApprovalBoxActions] GET inbox error:', response.status); + if (error?.__authError) { + return { data: [], total: 0, lastPage: 1, __authError: true }; + } + + if (!response) { + console.error('[ApprovalBoxActions] GET inbox error:', error?.message); return { data: [], total: 0, lastPage: 1 }; } const result: ApiResponse> = await response.json(); - if (!result.success || !result.data?.data) { + if (!response.ok || !result.success || !result.data?.data) { console.warn('[ApprovalBoxActions] No data in response'); return { data: [], total: 0, lastPage: 1 }; } @@ -228,25 +232,19 @@ export async function getInbox(params?: { */ export async function getInboxSummary(): Promise { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/inbox/summary`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - console.error('[ApprovalBoxActions] GET inbox/summary error:', response.status); + if (error?.__authError || !response) { + console.error('[ApprovalBoxActions] GET inbox/summary error:', error?.message); return null; } const result: ApiResponse = await response.json(); - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return null; } @@ -260,19 +258,24 @@ export async function getInboxSummary(): Promise { /** * 승인 처리 */ -export async function approveDocument(id: string, comment?: string): Promise<{ success: boolean; error?: string }> { +export async function approveDocument(id: string, comment?: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/approve`, { method: 'POST', - headers, body: JSON.stringify({ comment: comment || '' }), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '승인 처리에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -295,7 +298,7 @@ export async function approveDocument(id: string, comment?: string): Promise<{ s /** * 반려 처리 */ -export async function rejectDocument(id: string, comment: string): Promise<{ success: boolean; error?: string }> { +export async function rejectDocument(id: string, comment: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { if (!comment?.trim()) { return { @@ -304,17 +307,22 @@ export async function rejectDocument(id: string, comment: string): Promise<{ suc }; } - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/reject`, { method: 'POST', - headers, body: JSON.stringify({ comment }), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '반려 처리에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { diff --git a/src/components/approval/DocumentCreate/ApprovalLineSection.tsx b/src/components/approval/DocumentCreate/ApprovalLineSection.tsx index 71eee4f9..d21b6c48 100644 --- a/src/components/approval/DocumentCreate/ApprovalLineSection.tsx +++ b/src/components/approval/DocumentCreate/ApprovalLineSection.tsx @@ -68,24 +68,23 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps ) : ( data.map((person, index) => ( -
+
{index + 1} handleChange(index, value)} > - {/* 이미 선택된 값이 있으면 직접 표시, 없으면 placeholder */} - {person.name && !person.id.startsWith('temp-') ? ( - {person.department} / {person.position} / {person.name} - ) : ( - - )} + + {person.name && !person.id.startsWith('temp-') + ? `${person.department || ''} / ${person.position || ''} / ${person.name}` + : null} + {employees.map((employee) => ( - {employee.department} / {employee.position} / {employee.name} + {employee.department || ''} / {employee.position || ''} / {employee.name} ))} diff --git a/src/components/approval/DocumentCreate/actions.ts b/src/components/approval/DocumentCreate/actions.ts index fb0c4aad..ef2d860b 100644 --- a/src/components/approval/DocumentCreate/actions.ts +++ b/src/components/approval/DocumentCreate/actions.ts @@ -11,6 +11,7 @@ 'use server'; import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { ExpenseEstimateItem, ApprovalPerson, @@ -74,21 +75,6 @@ interface ApprovalCreateResponse { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * 비용견적서 API 데이터 → 프론트엔드 데이터 변환 */ @@ -141,6 +127,8 @@ function transformEmployee(employee: EmployeeApiData): ApprovalPerson { * 파일 업로드 * @param files 업로드할 파일 배열 * @returns 업로드된 파일 정보 배열 + * + * NOTE: 파일 업로드는 multipart/form-data가 필요하므로 serverFetch 대신 직접 fetch 사용 */ export async function uploadFiles(files: File[]): Promise<{ success: boolean; @@ -210,7 +198,6 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{ finalDifference: number; } | null> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (yearMonth) { @@ -219,12 +206,15 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{ const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/reports/expense-estimate?${searchParams.toString()}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, - cache: 'no-store', }); + if (error || !response) { + console.error('[DocumentCreateActions] GET expense-estimate error:', error?.message); + return null; + } + if (!response.ok) { console.error('[DocumentCreateActions] GET expense-estimate error:', response.status); return null; @@ -254,7 +244,6 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{ */ export async function getEmployees(search?: string): Promise { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); searchParams.set('per_page', '100'); if (search) { @@ -263,12 +252,15 @@ export async function getEmployees(search?: string): Promise { const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees?${searchParams.toString()}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, - cache: 'no-store', }); + if (error || !response) { + console.error('[DocumentCreateActions] GET employees error:', error?.message); + return []; + } + if (!response.ok) { console.error('[DocumentCreateActions] GET employees error:', response.status); return []; @@ -296,8 +288,6 @@ export async function createApproval(formData: DocumentFormData): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - // 새 첨부파일 업로드 const newFiles = formData.proposalData?.attachments || formData.expenseReportData?.attachments @@ -332,15 +322,21 @@ export async function createApproval(formData: DocumentFormData): Promise<{ content: getDocumentContent(formData, uploadedFiles), }; - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals`, { method: 'POST', - headers, body: JSON.stringify(requestBody), } ); + if (error || !response) { + return { + success: false, + error: error?.message || '문서 저장에 실패했습니다.', + }; + } + const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { @@ -374,17 +370,21 @@ export async function submitApproval(id: number): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`, { method: 'POST', - headers, body: JSON.stringify({}), } ); + if (error || !response) { + return { + success: false, + error: error?.message || '문서 상신에 실패했습니다.', + }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -450,17 +450,17 @@ export async function getApprovalById(id: number): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, { method: 'GET', - headers, - cache: 'no-store', } ); + if (error || !response) { + return { success: false, error: error?.message || '문서 조회에 실패했습니다.' }; + } + if (!response.ok) { if (response.status === 404) { return { success: false, error: '문서를 찾을 수 없습니다.' }; @@ -476,9 +476,9 @@ export async function getApprovalById(id: number): Promise<{ // API 응답을 프론트엔드 형식으로 변환 const apiData = result.data; - const formData = transformApiToFormData(apiData); + const formDataResult = transformApiToFormData(apiData); - return { success: true, data: formData }; + return { success: true, data: formDataResult }; } catch (error) { console.error('[DocumentCreateActions] getApprovalById error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; @@ -494,8 +494,6 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr error?: string; }> { try { - const headers = await getApiHeaders(); - // 새 첨부파일 업로드 const newFiles = formData.proposalData?.attachments || formData.expenseReportData?.attachments @@ -528,15 +526,21 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr content: getDocumentContent(formData, uploadedFiles), }; - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, { method: 'PATCH', - headers, body: JSON.stringify(requestBody), } ); + if (error || !response) { + return { + success: false, + error: error?.message || '문서 수정에 실패했습니다.', + }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -601,16 +605,20 @@ export async function deleteApproval(id: number): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, { method: 'DELETE', - headers, } ); + if (error || !response) { + return { + success: false, + error: error?.message || '문서 삭제에 실패했습니다.', + }; + } + const result = await response.json(); if (!response.ok || !result.success) { diff --git a/src/components/approval/DraftBox/actions.ts b/src/components/approval/DraftBox/actions.ts index 11035cf0..0965fbf0 100644 --- a/src/components/approval/DraftBox/actions.ts +++ b/src/components/approval/DraftBox/actions.ts @@ -12,7 +12,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { DraftRecord, DocumentStatus, Approver } from './types'; // ============================================ @@ -85,21 +85,6 @@ interface ApprovalStepApiData { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 상태 → 프론트엔드 상태 변환 */ @@ -193,9 +178,8 @@ export async function getDrafts(params?: { status?: string; sort_by?: string; sort_dir?: 'asc' | 'desc'; -}): Promise<{ data: DraftRecord[]; total: number; lastPage: number }> { +}): Promise<{ data: DraftRecord[]; total: number; lastPage: number; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -217,27 +201,20 @@ export async function getDrafts(params?: { const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/drafts?${searchParams.toString()}`; - console.log('[DraftBoxActions] Fetching:', url); - console.log('[DraftBoxActions] Headers:', JSON.stringify(headers, null, 2)); + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + if (error?.__authError) { + return { data: [], total: 0, lastPage: 1, __authError: true }; + } - console.log('[DraftBoxActions] Response status:', response.status); - - if (!response.ok) { - const errorText = await response.text(); - console.error('[DraftBoxActions] GET drafts error:', response.status, errorText); + if (!response) { + console.error('[DraftBoxActions] GET drafts error:', error?.message); return { data: [], total: 0, lastPage: 1 }; } const result: ApiResponse> = await response.json(); - console.log('[DraftBoxActions] Result:', JSON.stringify(result, null, 2).slice(0, 500)); - if (!result.success || !result.data?.data) { + if (!response.ok || !result.success || !result.data?.data) { console.warn('[DraftBoxActions] No data in response'); return { data: [], total: 0, lastPage: 1 }; } @@ -258,25 +235,19 @@ export async function getDrafts(params?: { */ export async function getDraftsSummary(): Promise { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/drafts/summary`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - console.error('[DraftBoxActions] GET summary error:', response.status); + if (error?.__authError || !response) { + console.error('[DraftBoxActions] GET summary error:', error?.message); return null; } const result: ApiResponse = await response.json(); - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return null; } @@ -292,25 +263,19 @@ export async function getDraftsSummary(): Promise { */ export async function getDraftById(id: string): Promise { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET' } ); - if (!response.ok) { - console.error('[DraftBoxActions] GET draft error:', response.status); + if (error?.__authError || !response) { + console.error('[DraftBoxActions] GET draft error:', error?.message); return null; } const result: ApiResponse = await response.json(); - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return null; } @@ -324,18 +289,21 @@ export async function getDraftById(id: string): Promise { /** * 결재 문서 삭제 (임시저장 상태만) */ -export async function deleteDraft(id: string): Promise<{ success: boolean; error?: string }> { +export async function deleteDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, - { - method: 'DELETE', - headers, - } + { method: 'DELETE' } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '결재 문서 삭제에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -382,19 +350,24 @@ export async function deleteDrafts(ids: string[]): Promise<{ success: boolean; f /** * 결재 상신 */ -export async function submitDraft(id: string): Promise<{ success: boolean; error?: string }> { +export async function submitDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`, { method: 'POST', - headers, body: JSON.stringify({}), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '결재 상신에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { @@ -441,19 +414,24 @@ export async function submitDrafts(ids: string[]): Promise<{ success: boolean; f /** * 결재 회수 (기안자만) */ -export async function cancelDraft(id: string): Promise<{ success: boolean; error?: string }> { +export async function cancelDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/cancel`, { method: 'POST', - headers, body: JSON.stringify({}), } ); + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '결재 회수에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { diff --git a/src/components/approval/ReferenceBox/actions.ts b/src/components/approval/ReferenceBox/actions.ts index b33cde7a..b56c9f0a 100644 --- a/src/components/approval/ReferenceBox/actions.ts +++ b/src/components/approval/ReferenceBox/actions.ts @@ -9,8 +9,8 @@ 'use server'; -import { cookies } from 'next/headers'; -import type { ReferenceRecord, ApprovalType, DocumentStatus, ReadStatus } from './types'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; +import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types'; // ============================================ // API 응답 타입 정의 @@ -72,21 +72,6 @@ interface ReferenceStepApiData { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 상태 → 프론트엔드 상태 변환 */ @@ -156,7 +141,6 @@ export async function getReferences(params?: { sort_dir?: 'asc' | 'desc'; }): Promise<{ data: ReferenceRecord[]; total: number; lastPage: number }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -173,12 +157,16 @@ export async function getReferences(params?: { const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/reference?${searchParams.toString()}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, - cache: 'no-store', }); + // serverFetch handles 401 with redirect, so we just check for other errors + if (error || !response) { + console.error('[ReferenceBoxActions] GET reference error:', error?.message); + return { data: [], total: 0, lastPage: 1 }; + } + if (!response.ok) { console.error('[ReferenceBoxActions] GET reference error:', response.status); return { data: [], total: 0, lastPage: 1 }; @@ -228,16 +216,20 @@ export async function getReferenceSummary(): Promise<{ all: number; read: number */ export async function markAsRead(id: string): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/read`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/read`, - { - method: 'POST', - headers, - body: JSON.stringify({}), - } - ); + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify({}), + }); + + // serverFetch handles 401 with redirect + if (error || !response) { + return { + success: false, + error: error?.message || '열람 처리에 실패했습니다.', + }; + } const result = await response.json(); @@ -263,16 +255,20 @@ export async function markAsRead(id: string): Promise<{ success: boolean; error? */ export async function markAsUnread(id: string): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/unread`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/unread`, - { - method: 'POST', - headers, - body: JSON.stringify({}), - } - ); + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify({}), + }); + + // serverFetch handles 401 with redirect + if (error || !response) { + return { + success: false, + error: error?.message || '미열람 처리에 실패했습니다.', + }; + } const result = await response.json(); diff --git a/src/components/approval/ReferenceBox/index.tsx b/src/components/approval/ReferenceBox/index.tsx index a03f1c32..27197856 100644 --- a/src/components/approval/ReferenceBox/index.tsx +++ b/src/components/approval/ReferenceBox/index.tsx @@ -5,7 +5,6 @@ import { Files, Eye, EyeOff, - Check, BookOpen, } from 'lucide-react'; import { toast } from 'sonner'; @@ -44,19 +43,22 @@ import { } from '@/components/templates/IntegratedListTemplateV2'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +import { DocumentDetailModal } from '@/components/approval/DocumentDetail'; +import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types'; import type { ReferenceTabType, ReferenceRecord, SortOption, FilterOption, + ApprovalType, } from './types'; import { REFERENCE_TAB_LABELS, SORT_OPTIONS, FILTER_OPTIONS, APPROVAL_TYPE_LABELS, - DOCUMENT_STATUS_LABELS, - DOCUMENT_STATUS_COLORS, + READ_STATUS_LABELS, + READ_STATUS_COLORS, } from './types'; // ===== 통계 타입 ===== @@ -86,6 +88,10 @@ export function ReferenceBox() { const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false); const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false); + // ===== 문서 상세 모달 상태 ===== + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedDocument, setSelectedDocument] = useState(null); + // API 데이터 const [data, setData] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -252,6 +258,87 @@ export function ReferenceBox() { setMarkUnreadDialogOpen(false); }, [selectedItems, loadData, loadSummary]); + // ===== 문서 클릭/상세 보기 핸들러 ===== + const handleDocumentClick = useCallback((item: ReferenceRecord) => { + setSelectedDocument(item); + setIsModalOpen(true); + }, []); + + // ===== ApprovalType → DocumentType 변환 ===== + const getDocumentType = (approvalType: ApprovalType): DocumentType => { + switch (approvalType) { + case 'expense_estimate': return 'expenseEstimate'; + case 'expense_report': return 'expenseReport'; + default: return 'proposal'; + } + }; + + // ===== ReferenceRecord → 모달용 데이터 변환 ===== + const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => { + const docType = getDocumentType(item.approvalType); + const drafter = { + id: 'drafter-1', + name: item.drafter, + position: item.drafterPosition, + department: item.drafterDepartment, + status: 'approved' as const, + }; + const approvers = [{ + id: 'approver-1', + name: '결재자', + position: '부장', + department: '경영지원팀', + status: 'approved' as const, + }]; + + switch (docType) { + case 'expenseEstimate': + return { + documentNo: item.documentNo, + createdAt: item.draftDate, + items: [ + { id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' }, + { id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' }, + ], + totalExpense: 3050000, + accountBalance: 25000000, + finalDifference: 21950000, + approvers, + drafter, + }; + case 'expenseReport': + return { + documentNo: item.documentNo, + createdAt: item.draftDate, + requestDate: item.draftDate, + paymentDate: item.draftDate, + items: [ + { id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' }, + { id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' }, + ], + cardInfo: '삼성카드 **** 1234', + totalAmount: 80000, + attachments: [], + approvers, + drafter, + }; + default: + return { + documentNo: item.documentNo, + createdAt: item.draftDate, + vendor: '거래처', + vendorPaymentDate: item.draftDate, + title: item.title, + description: item.title, + reason: '업무상 필요', + estimatedCost: 1000000, + attachments: [], + approvers, + drafter, + }; + } + }; + // ===== 통계 카드 ===== const statCards: StatCard[] = useMemo(() => [ { label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' }, @@ -267,7 +354,7 @@ export function ReferenceBox() { ], [stats]); // ===== 테이블 컬럼 ===== - // 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태, 확인 + // 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태 const tableColumns: TableColumn[] = useMemo(() => [ { key: 'no', label: '번호', className: 'w-[60px] text-center' }, { key: 'documentNo', label: '문서번호' }, @@ -276,7 +363,6 @@ export function ReferenceBox() { { key: 'drafter', label: '기안자' }, { key: 'draftDate', label: '기안일시' }, { key: 'status', label: '상태', className: 'text-center' }, - { key: 'confirm', label: '확인', className: 'w-[80px] text-center' }, ], []); // ===== 테이블 행 렌더링 ===== @@ -284,8 +370,12 @@ export function ReferenceBox() { const isSelected = selectedItems.has(item.id); return ( - - + handleDocumentClick(item)} + > + e.stopPropagation()}> toggleSelection(item.id)} /> {globalIndex} @@ -297,18 +387,13 @@ export function ReferenceBox() { {item.drafter} {item.draftDate} - - {DOCUMENT_STATUS_LABELS[item.documentStatus]} + + {READ_STATUS_LABELS[item.readStatus]} - - {item.readStatus === 'read' && ( - - )} - ); - }, [selectedItems, toggleSelection]); + }, [selectedItems, toggleSelection, handleDocumentClick]); // ===== 모바일 카드 렌더링 ===== const renderMobileCard = useCallback(( @@ -325,14 +410,9 @@ export function ReferenceBox() { headerBadges={
{APPROVAL_TYPE_LABELS[item.approvalType]} - - {DOCUMENT_STATUS_LABELS[item.documentStatus]} + + {READ_STATUS_LABELS[item.readStatus]} - {item.readStatus === 'read' ? ( - 열람 - ) : ( - 미열람 - )}
} isSelected={isSelected} @@ -505,6 +585,17 @@ export function ReferenceBox() { + + {/* 문서 상세 모달 */} + {selectedDocument && ( + + )} ); } \ No newline at end of file diff --git a/src/components/attendance/actions.ts b/src/components/attendance/actions.ts index aa9a1b73..b5f25d89 100644 --- a/src/components/attendance/actions.ts +++ b/src/components/attendance/actions.ts @@ -9,7 +9,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; // ============================================ // 타입 정의 @@ -75,21 +75,6 @@ interface PaginatedResponse { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 응답에서 프론트엔드 형식으로 변환 */ @@ -117,12 +102,10 @@ function transformApiToFrontend(apiData: Record): AttendanceRec */ export async function checkIn( data: CheckInRequest -): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> { +): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${process.env.API_URL}/v1/attendances/check-in`, { + const { response, error } = await serverFetch(`${process.env.API_URL}/v1/attendances/check-in`, { method: 'POST', - headers, body: JSON.stringify({ user_id: data.userId, check_in: data.checkIn, @@ -136,6 +119,14 @@ export async function checkIn( }), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '출근 기록에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -164,12 +155,10 @@ export async function checkIn( */ export async function checkOut( data: CheckOutRequest -): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> { +): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${process.env.API_URL}/v1/attendances/check-out`, { + const { response, error } = await serverFetch(`${process.env.API_URL}/v1/attendances/check-out`, { method: 'POST', - headers, body: JSON.stringify({ user_id: data.userId, check_out: data.checkOut, @@ -183,6 +172,14 @@ export async function checkOut( }), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '퇴근 기록에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -213,19 +210,26 @@ export async function getTodayAttendance(): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); const today = new Date().toISOString().split('T')[0]; - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.API_URL}/v1/attendances?date=${today}&per_page=1`, { method: 'GET', - headers, } ); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '근태 조회에 실패했습니다.' }; + } + const result: ApiResponse>> = await response.json(); if (result.success && result.data) { diff --git a/src/components/board/BoardManagement/actions.ts b/src/components/board/BoardManagement/actions.ts index 88465b8e..0643b744 100644 --- a/src/components/board/BoardManagement/actions.ts +++ b/src/components/board/BoardManagement/actions.ts @@ -12,7 +12,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { Board, BoardApiData, BoardFormData } from './types'; // API 응답 타입 @@ -22,21 +22,6 @@ interface ApiResponse { message: string; } -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 데이터 → 프론트엔드 타입 변환 */ @@ -89,10 +74,8 @@ function transformFrontendToApi(data: BoardFormData & { boardCode?: string; desc export async function getBoards(filters?: { board_type?: string; search?: string; -}): Promise<{ success: boolean; data?: Board[]; error?: string }> { +}): Promise<{ success: boolean; data?: Board[]; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const params = new URLSearchParams(); if (filters?.board_type) params.append('board_type', filters.board_type); if (filters?.search) params.append('search', filters.search); @@ -101,14 +84,16 @@ export async function getBoards(filters?: { // 테넌트 게시판만 조회 (시스템 게시판은 mng에서 관리) const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/tenant${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[BoardActions] GET boards error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시판 목록 조회에 실패했습니다.' }; } @@ -120,7 +105,7 @@ export async function getBoards(filters?: { return { success: false, error: '서버 응답 형식 오류입니다.' }; } - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, error: result.message || '게시판 목록 조회에 실패했습니다.' }; } @@ -143,10 +128,8 @@ export async function getBoards(filters?: { export async function getTenantBoards(filters?: { board_type?: string; search?: string; -}): Promise<{ success: boolean; data?: Board[]; error?: string }> { +}): Promise<{ success: boolean; data?: Board[]; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const params = new URLSearchParams(); if (filters?.board_type) params.append('board_type', filters.board_type); if (filters?.search) params.append('search', filters.search); @@ -154,20 +137,28 @@ export async function getTenantBoards(filters?: { const queryString = params.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/tenant${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[BoardActions] GET tenant boards error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '테넌트 게시판 목록 조회에 실패했습니다.' }; } - const result: ApiResponse = await response.json(); + let result: ApiResponse; + try { + result = await response.json(); + } catch { + console.error('[BoardActions] JSON parse error'); + return { success: false, error: '서버 응답 형식 오류입니다.' }; + } - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, error: result.message || '테넌트 게시판 목록 조회에 실패했습니다.' }; } @@ -182,27 +173,32 @@ export async function getTenantBoards(filters?: { /** * 게시판 상세 조회 (코드 기반) */ -export async function getBoardByCode(code: string): Promise<{ success: boolean; data?: Board; error?: string }> { +export async function getBoardByCode(code: string): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${code}`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${code}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); + const { response, error } = await serverFetch(url, { + method: 'GET', + cache: 'no-store', + }); - if (!response.ok) { - console.error('[BoardActions] GET board error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시판 조회에 실패했습니다.' }; } - const result: ApiResponse = await response.json(); + let result: ApiResponse; + try { + result = await response.json(); + } catch { + console.error('[BoardActions] JSON parse error'); + return { success: false, error: '서버 응답 형식 오류입니다.' }; + } - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '게시판을 찾을 수 없습니다.' }; } @@ -216,27 +212,32 @@ export async function getBoardByCode(code: string): Promise<{ success: boolean; /** * 게시판 상세 조회 (ID 기반) */ -export async function getBoardById(id: string): Promise<{ success: boolean; data?: Board; error?: string }> { +export async function getBoardById(id: string): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); + const { response, error } = await serverFetch(url, { + method: 'GET', + cache: 'no-store', + }); - if (!response.ok) { - console.error('[BoardActions] GET board by id error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시판 조회에 실패했습니다.' }; } - const result: ApiResponse = await response.json(); + let result: ApiResponse; + try { + result = await response.json(); + } catch { + console.error('[BoardActions] JSON parse error'); + return { success: false, error: '서버 응답 형식 오류입니다.' }; + } - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '게시판을 찾을 수 없습니다.' }; } @@ -252,21 +253,31 @@ export async function getBoardById(id: string): Promise<{ success: boolean; data */ export async function createBoard( data: BoardFormData & { boardCode: string; description?: string } -): Promise<{ success: boolean; data?: Board; error?: string }> { +): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const result = await response.json(); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시판 생성에 실패했습니다.' }; + } + + let result: ApiResponse; + try { + result = await response.json(); + } catch { + console.error('[BoardActions] JSON parse error'); + return { success: false, error: '서버 응답 형식 오류입니다.' }; + } if (!response.ok || !result.success) { return { @@ -291,21 +302,31 @@ export async function createBoard( export async function updateBoard( id: string, data: BoardFormData & { boardCode?: string; description?: string } -): Promise<{ success: boolean; data?: Board; error?: string }> { +): Promise<{ success: boolean; data?: Board; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data, true); // isUpdate=true + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } - ); + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const result = await response.json(); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시판 수정에 실패했습니다.' }; + } + + let result: ApiResponse; + try { + result = await response.json(); + } catch { + console.error('[BoardActions] JSON parse error'); + return { success: false, error: '서버 응답 형식 오류입니다.' }; + } if (!response.ok || !result.success) { return { @@ -327,19 +348,29 @@ export async function updateBoard( /** * 게시판 삭제 */ -export async function deleteBoard(id: string): Promise<{ success: boolean; error?: string }> { +export async function deleteBoard(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`; - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${id}`, - { - method: 'DELETE', - headers, - } - ); + const { response, error } = await serverFetch(url, { + method: 'DELETE', + }); - const result = await response.json(); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시판 삭제에 실패했습니다.' }; + } + + let result: { success: boolean; message?: string }; + try { + result = await response.json(); + } catch { + console.error('[BoardActions] JSON parse error'); + return { success: false, error: '서버 응답 형식 오류입니다.' }; + } if (!response.ok || !result.success) { return { @@ -358,10 +389,15 @@ export async function deleteBoard(id: string): Promise<{ success: boolean; error /** * 게시판 일괄 삭제 */ -export async function deleteBoardsBulk(ids: string[]): Promise<{ success: boolean; error?: string }> { +export async function deleteBoardsBulk(ids: string[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { const results = await Promise.all(ids.map(id => deleteBoard(id))); const failed = results.filter(r => !r.success); + const hasAuthError = results.some(r => r.__authError); + + if (hasAuthError) { + return { success: false, error: '인증이 만료되었습니다.', __authError: true }; + } if (failed.length > 0) { return { diff --git a/src/components/board/actions.ts b/src/components/board/actions.ts index b4ba8461..fee70de4 100644 --- a/src/components/board/actions.ts +++ b/src/components/board/actions.ts @@ -12,7 +12,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { PostApiData, PostPaginationResponse, @@ -21,21 +21,6 @@ import type { Post, } from './types'; -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * API 데이터 → 프론트엔드 타입 변환 */ @@ -74,10 +59,8 @@ function transformApiToPost(apiData: PostApiData, boardName?: string): Post { export async function getPosts( boardCode: string, filters?: PostFilters -): Promise<{ success: boolean; data?: PostPaginationResponse; posts?: Post[]; error?: string }> { +): Promise<{ success: boolean; data?: PostPaginationResponse; posts?: Post[]; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const params = new URLSearchParams(); if (filters?.search) params.append('search', filters.search); if (filters?.is_notice !== undefined) params.append('is_notice', String(filters.is_notice)); @@ -88,14 +71,16 @@ export async function getPosts( const queryString = params.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[BoardActions] GET posts error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시글 목록 조회에 실패했습니다.' }; } @@ -107,7 +92,7 @@ export async function getPosts( return { success: false, error: '서버 응답 형식 오류입니다.' }; } - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, error: result.message || '게시글 목록 조회에 실패했습니다.' }; } @@ -126,10 +111,8 @@ export async function getPosts( */ export async function getMyPosts( filters?: PostFilters -): Promise<{ success: boolean; data?: PostPaginationResponse; posts?: Post[]; error?: string }> { +): Promise<{ success: boolean; data?: PostPaginationResponse; posts?: Post[]; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const params = new URLSearchParams(); if (filters?.search) params.append('search', filters.search); if (filters?.board_code) params.append('board_code', filters.board_code); @@ -140,14 +123,16 @@ export async function getMyPosts( const queryString = params.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/my-posts${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[BoardActions] GET my-posts error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '나의 게시글 조회에 실패했습니다.' }; } @@ -159,7 +144,7 @@ export async function getMyPosts( return { success: false, error: '서버 응답 형식 오류입니다.' }; } - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, error: result.message || '나의 게시글 조회에 실패했습니다.' }; } @@ -179,26 +164,26 @@ export async function getMyPosts( export async function getPost( boardCode: string, postId: number | string -): Promise<{ success: boolean; data?: Post; error?: string }> { +): Promise<{ success: boolean; data?: Post; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[BoardActions] GET post error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시글 조회에 실패했습니다.' }; } const result: ApiResponse = await response.json(); - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '게시글을 찾을 수 없습니다.' }; } @@ -221,25 +206,27 @@ export async function createPost( is_secret?: boolean; custom_fields?: Record; } -): Promise<{ success: boolean; data?: Post; error?: string }> { +): Promise<{ success: boolean; data?: Post; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'POST', - headers, body: JSON.stringify(data), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시글 등록에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '게시글 등록에 실패했습니다.', - }; + return { success: false, error: result.message || '게시글 등록에 실패했습니다.' }; } return { success: true, data: transformApiToPost(result.data) }; @@ -262,25 +249,27 @@ export async function updatePost( is_secret?: boolean; custom_fields?: Record; } -): Promise<{ success: boolean; data?: Post; error?: string }> { +): Promise<{ success: boolean; data?: Post; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'PUT', - headers, body: JSON.stringify(data), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시글 수정에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '게시글 수정에 실패했습니다.', - }; + return { success: false, error: result.message || '게시글 수정에 실패했습니다.' }; } return { success: true, data: transformApiToPost(result.data) }; @@ -296,24 +285,26 @@ export async function updatePost( export async function deletePost( boardCode: string, postId: number | string -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'DELETE', - headers, }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시글 삭제에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '게시글 삭제에 실패했습니다.', - }; + return { success: false, error: result.message || '게시글 삭제에 실패했습니다.' }; } return { success: true }; @@ -321,4 +312,4 @@ export async function deletePost( console.error('[BoardActions] deletePost error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } -} \ No newline at end of file +} diff --git a/src/components/business/juil/JuilDashboard.tsx b/src/components/business/juil/JuilDashboard.tsx new file mode 100644 index 00000000..cea2c8a5 --- /dev/null +++ b/src/components/business/juil/JuilDashboard.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Suspense } from "react"; +import { JuilMainDashboard } from "./JuilMainDashboard"; +import { PageLoadingSpinner } from "@/components/ui/loading-spinner"; + +/** + * JuilDashboard - 주일기업 전용 대시보드 + * + * 건설/공사 프로젝트 중심의 메트릭과 현황을 보여줍니다. + */ +export function JuilDashboard() { + console.log('🏗️ Juil Dashboard rendering...'); + return ( + }> + + + ); +} diff --git a/src/components/business/juil/JuilMainDashboard.tsx b/src/components/business/juil/JuilMainDashboard.tsx new file mode 100644 index 00000000..90b01c2b --- /dev/null +++ b/src/components/business/juil/JuilMainDashboard.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useCurrentTime } from "@/hooks/useCurrentTime"; +import { + Building2, + MapPin, + HardHat, + Truck, + AlertTriangle, + FileText, + Calendar as CalendarIcon, + CheckCircle2, + Clock, + ArrowUpRight, + ClipboardList, + Hammer +} from "lucide-react"; + +export function JuilMainDashboard() { + const currentTime = useCurrentTime(); + + // 가상 데이터: 건설 프로젝트 현황 + const projectStats = { + totalProjects: 12, + active: 5, // 진행중 + bidding: 3, // 입찰중 + planning: 4, // 설계/계획 + }; + + const recentProjects = [ + { id: 1, name: "강남 데이터센터 신축공사", status: "공사중", progress: 45, manager: "김현장", deadline: "2024-12-30" }, + { id: 2, name: "판교 오피스 리모델링", status: "마감", progress: 95, manager: "이공사", deadline: "2024-06-15" }, + { id: 3, name: "부산 물류센터 증축", status: "착공준비", progress: 5, manager: "박안전", deadline: "2025-03-20" }, + ]; + + const upcomingEvents = [ + { id: 1, type: "현장설명회", title: "송도 주상복합 입찰 설명회", date: "2024-06-25 14:00", location: "인천 송도 현장" }, + { id: 2, type: "입찰마감", title: "세종 스마트시티 관로공사", date: "2024-06-28 17:00", location: "전자조달" }, + ]; + + return ( +
+ {/* 헤더 섹션 */} +
+
+
+

+ + 주일기업 프로젝트 현황 +

+

+ {currentTime} · 실시간 현장 모니터링 +

+
+
+ +
+
+
+ + {/* 상단 통계 카드 */} +
+ + + 진행중인 현장 + + + +
{projectStats.active}개소
+

+ 총 {projectStats.totalProjects}개 프로젝트 중 +

+
+
+ + + 금일 출력 인원 + + + +
142명
+

+ +12명 (전일 대비) +

+
+
+ + + 입찰 진행 + + + +
{projectStats.bidding}건
+

+ 마감 임박 1건 +

+
+
+ + + 안전 이슈 + + + +
0건
+

+ 무재해 125일째 +

+
+
+
+ +
+ {/* 주요 프로젝트 진행 현황 */} + + + + + 주요 프로젝트 진행 현황 + + + +
+ {recentProjects.map((project) => ( +
+
+
+ {project.name} + + {project.status} + +
+
+ + {project.manager} + + + {project.deadline}까지 + +
+
+
+
{project.progress}%
+
공정률
+
+
+ ))} +
+
+
+ + {/* 일정 / 알림 */} + + + + + 주요 일정 (현설/입찰) + + + +
+ {upcomingEvents.map((event) => ( +
+
+ + {event.type} + +

{event.title}

+
+ {event.date} + | + {event.location} +
+
+
+ ))} + +
+
+
+
+ +
+ ); +} diff --git a/src/components/business/juil/partners/PartnerListClient.tsx b/src/components/business/juil/partners/PartnerListClient.tsx new file mode 100644 index 00000000..171b031d --- /dev/null +++ b/src/components/business/juil/partners/PartnerListClient.tsx @@ -0,0 +1,493 @@ +'use client'; + +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Building2, Plus, Pencil, Trash2, AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { TableCell, TableRow } from '@/components/ui/table'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { IntegratedListTemplateV2, TabOption, TableColumn } from '@/components/templates/IntegratedListTemplateV2'; +import { MobileCard } from '@/components/molecules/MobileCard'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import type { Partner, PartnerStats } from './types'; +import { getPartnerList, deletePartner, deletePartners, getPartnerStats } from './actions'; + +// 테이블 컬럼 정의 +const tableColumns: TableColumn[] = [ + { key: 'no', label: '번호', className: 'w-[60px] text-center' }, + { key: 'partnerCode', label: '거래처번호', className: 'w-[100px]' }, + { key: 'category', label: '구분', className: 'w-[80px] text-center' }, + { key: 'partnerName', label: '거래처명', className: 'min-w-[150px]' }, + { key: 'representative', label: '대표자', className: 'w-[100px]' }, + { key: 'manager', label: '담당자', className: 'w-[100px]' }, + { key: 'phone', label: '전화번호', className: 'w-[130px]' }, + { key: 'paymentDay', label: '매출 결제일', className: 'w-[100px] text-center' }, + { key: 'isBadDebt', label: '악성채권', className: 'w-[90px] text-center' }, + { key: 'actions', label: '작업', className: 'w-[100px] text-center' }, +]; + +interface PartnerListClientProps { + initialData?: Partner[]; + initialStats?: PartnerStats; +} + +export default function PartnerListClient({ initialData = [], initialStats }: PartnerListClientProps) { + const router = useRouter(); + + // 상태 + const [partners, setPartners] = useState(initialData); + const [stats, setStats] = useState( + initialStats ?? { total: 0, unregistered: 0, badDebt: 0, normal: 0 } + ); + const [activeTab, setActiveTab] = useState('all'); + const [searchValue, setSearchValue] = useState(''); + const [badDebtFilter, setBadDebtFilter] = useState<'all' | 'badDebt' | 'normal'>('all'); + const [sortBy, setSortBy] = useState<'latest' | 'oldest' | 'nameAsc' | 'nameDesc'>('latest'); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const itemsPerPage = 20; + + // 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [listResult, statsResult] = await Promise.all([ + getPartnerList({ size: 1000, badDebtFilter, sortBy }), + getPartnerStats(), + ]); + + if (listResult.success && listResult.data) { + setPartners(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } catch { + toast.error('데이터 로드에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }, [badDebtFilter, sortBy]); + + // 초기 데이터가 없으면 로드 + useEffect(() => { + if (initialData.length === 0) { + loadData(); + } + }, [initialData.length, loadData]); + + // 필터링된 데이터 + const filteredPartners = useMemo(() => { + return partners.filter((partner) => { + // 탭 필터 (전체/신규) + if (activeTab === 'new') { + // 신규 조건 (예: 최근 7일 이내 등록) + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + if (new Date(partner.createdAt) < sevenDaysAgo) { + return false; + } + } + + // 악성채권 필터 + if (badDebtFilter === 'badDebt' && !partner.isBadDebt) { + return false; + } + if (badDebtFilter === 'normal' && partner.isBadDebt) { + return false; + } + + // 검색 필터 + if (searchValue) { + const search = searchValue.toLowerCase(); + return ( + partner.partnerCode.toLowerCase().includes(search) || + partner.partnerName.toLowerCase().includes(search) || + partner.representative.toLowerCase().includes(search) || + partner.manager.toLowerCase().includes(search) + ); + } + return true; + }); + }, [partners, activeTab, badDebtFilter, searchValue]); + + // 정렬 + const sortedPartners = useMemo(() => { + const sorted = [...filteredPartners]; + switch (sortBy) { + case 'latest': + sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + break; + case 'oldest': + sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + break; + case 'nameAsc': + sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName)); + break; + case 'nameDesc': + sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName)); + break; + } + return sorted; + }, [filteredPartners, sortBy]); + + // 페이지네이션 + const totalPages = Math.ceil(sortedPartners.length / itemsPerPage); + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * itemsPerPage; + return sortedPartners.slice(start, start + itemsPerPage); + }, [sortedPartners, currentPage, itemsPerPage]); + + // 탭 옵션 + const tabOptions: TabOption[] = useMemo( + () => [ + { value: 'all', label: '전체', count: stats.total }, + { value: 'new', label: '신규', count: stats.unregistered }, + ], + [stats] + ); + + // 핸들러 + const handleTabChange = useCallback((value: string) => { + setActiveTab(value); + setCurrentPage(1); + }, []); + + const handleSearchChange = useCallback((value: string) => { + setSearchValue(value); + setCurrentPage(1); + }, []); + + const handleToggleSelection = useCallback((id: string) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }, []); + + const handleToggleSelectAll = useCallback(() => { + if (selectedItems.size === paginatedData.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(paginatedData.map((p) => p.id))); + } + }, [selectedItems.size, paginatedData]); + + const handleRowClick = useCallback( + (partner: Partner) => { + router.push(`/ko/juil/project/bidding/partners/${partner.id}`); + }, + [router] + ); + + const handleCreate = useCallback(() => { + router.push('/ko/juil/project/bidding/partners/new'); + }, [router]); + + const handleEdit = useCallback( + (e: React.MouseEvent, partnerId: string) => { + e.stopPropagation(); + router.push(`/ko/juil/project/bidding/partners/${partnerId}/edit`); + }, + [router] + ); + + const handleDeleteClick = useCallback((e: React.MouseEvent, partnerId: string) => { + e.stopPropagation(); + setDeleteTargetId(partnerId); + setDeleteDialogOpen(true); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteTargetId) return; + + setIsLoading(true); + try { + const result = await deletePartner(deleteTargetId); + if (result.success) { + toast.success('거래처가 삭제되었습니다.'); + setPartners((prev) => prev.filter((p) => p.id !== deleteTargetId)); + setSelectedItems((prev) => { + const newSet = new Set(prev); + newSet.delete(deleteTargetId); + return newSet; + }); + // 통계 재조회 + const statsResult = await getPartnerStats(); + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch { + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + setDeleteDialogOpen(false); + setDeleteTargetId(null); + } + }, [deleteTargetId]); + + const handleBulkDeleteClick = useCallback(() => { + if (selectedItems.size === 0) { + toast.warning('삭제할 항목을 선택해주세요.'); + return; + } + setBulkDeleteDialogOpen(true); + }, [selectedItems.size]); + + const handleBulkDeleteConfirm = useCallback(async () => { + if (selectedItems.size === 0) return; + + setIsLoading(true); + try { + const ids = Array.from(selectedItems); + const result = await deletePartners(ids); + if (result.success) { + toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`); + await loadData(); + setSelectedItems(new Set()); + } else { + toast.error(result.error || '일괄 삭제에 실패했습니다.'); + } + } catch { + toast.error('일괄 삭제 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + setBulkDeleteDialogOpen(false); + } + }, [selectedItems, loadData]); + + // 테이블 행 렌더링 + const renderTableRow = useCallback( + (partner: Partner, index: number, globalIndex: number) => { + const isSelected = selectedItems.has(partner.id); + + return ( + handleRowClick(partner)} + > + e.stopPropagation()}> + handleToggleSelection(partner.id)} + /> + + {globalIndex} + {partner.partnerCode} + + {partner.category} + + {partner.partnerName} + {partner.representative} + {partner.manager} + {partner.phone} + + {partner.paymentDay ? `${partner.paymentDay}일` : '-'} + + + {partner.isBadDebt ? ( + 악성채권 + ) : ( + - + )} + + + {isSelected && ( +
+ + +
+ )} +
+
+ ); + }, + [selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick] + ); + + // 모바일 카드 렌더링 + const renderMobileCard = useCallback( + (partner: Partner, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => { + return ( + handleRowClick(partner)} + details={[ + { label: '구분', value: partner.category }, + { label: '대표자', value: partner.representative }, + { label: '담당자', value: partner.manager }, + { label: '전화번호', value: partner.phone }, + { label: '매출 결제일', value: partner.paymentDay ? `${partner.paymentDay}일` : '-' }, + ]} + /> + ); + }, + [handleRowClick] + ); + + // 헤더 액션 (필터 + 등록 버튼) + const headerActions = ( +
+ {/* 악성채권 필터 */} + + + {/* 정렬 */} + + + {/* 등록 버튼 */} + +
+ ); + + return ( + <> + item.id} + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + selectedItems={selectedItems} + onToggleSelection={handleToggleSelection} + onToggleSelectAll={handleToggleSelectAll} + onBulkDelete={handleBulkDeleteClick} + pagination={{ + currentPage, + totalPages, + totalItems: sortedPartners.length, + itemsPerPage, + onPageChange: setCurrentPage, + }} + /> + + {/* 단일 삭제 다이얼로그 */} + + + + 거래처 삭제 + + 선택한 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + 삭제 + + + + + {/* 일괄 삭제 다이얼로그 */} + + + + 거래처 일괄 삭제 + + 선택한 {selectedItems.size}개 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + 삭제 + + + + + ); +} \ No newline at end of file diff --git a/src/components/business/juil/partners/actions.ts b/src/components/business/juil/partners/actions.ts new file mode 100644 index 00000000..ffe124ea --- /dev/null +++ b/src/components/business/juil/partners/actions.ts @@ -0,0 +1,223 @@ +'use server'; + +import type { Partner, PartnerStats, PartnerFilter, PartnerListResponse } from './types'; + +/** + * 주일 기업 - 거래처 관리 Server Actions + * TODO: 실제 API 연동 시 구현 + */ + +// 목업 데이터 +const mockPartners: Partner[] = [ + { + id: '1', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '010-1234-1234', + paymentDay: 15, + isBadDebt: false, + isActive: true, + createdAt: '2025-01-01', + updatedAt: '2025-01-01', + }, + { + id: '2', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '02-1234-1234', + paymentDay: 15, + isBadDebt: false, + isActive: true, + createdAt: '2025-01-02', + updatedAt: '2025-01-02', + }, + { + id: '3', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '010-1234-1234', + paymentDay: 15, + isBadDebt: true, + isActive: true, + createdAt: '2025-01-03', + updatedAt: '2025-01-03', + }, + { + id: '4', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '02-1234-1234', + paymentDay: 15, + isBadDebt: false, + isActive: true, + createdAt: '2025-01-04', + updatedAt: '2025-01-04', + }, + { + id: '5', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '010-1234-1234', + paymentDay: 15, + isBadDebt: false, + isActive: true, + createdAt: '2025-01-05', + updatedAt: '2025-01-05', + }, + { + id: '6', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '010-1234-1234', + paymentDay: 15, + isBadDebt: false, + isActive: true, + createdAt: '2025-01-06', + updatedAt: '2025-01-06', + }, + { + id: '7', + partnerCode: '123123', + category: '매출', + partnerName: '회사명', + representative: '이름', + manager: '이름', + phone: '010-1234-1234', + paymentDay: 15, + isBadDebt: false, + isActive: true, + createdAt: '2025-01-07', + updatedAt: '2025-01-07', + }, +]; + +// 거래처 목록 조회 +export async function getPartnerList( + filter?: PartnerFilter +): Promise<{ success: boolean; data?: PartnerListResponse; error?: string }> { + try { + // TODO: 실제 API 호출 + // const response = await fetch(`${API_URL}/partners`, { ... }); + + let filtered = [...mockPartners]; + + // 검색 필터 + if (filter?.search) { + const search = filter.search.toLowerCase(); + filtered = filtered.filter( + (p) => + p.partnerName.toLowerCase().includes(search) || + p.partnerCode.toLowerCase().includes(search) || + p.representative.toLowerCase().includes(search) + ); + } + + // 악성채권 필터 + if (filter?.badDebtFilter && filter.badDebtFilter !== 'all') { + filtered = filtered.filter((p) => + filter.badDebtFilter === 'badDebt' ? p.isBadDebt : !p.isBadDebt + ); + } + + // 정렬 + if (filter?.sortBy) { + switch (filter.sortBy) { + case 'latest': + filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + break; + case 'oldest': + filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + break; + case 'nameAsc': + filtered.sort((a, b) => a.partnerName.localeCompare(b.partnerName)); + break; + case 'nameDesc': + filtered.sort((a, b) => b.partnerName.localeCompare(a.partnerName)); + break; + } + } + + const page = filter?.page ?? 1; + const size = filter?.size ?? 20; + const start = (page - 1) * size; + const paginatedItems = filtered.slice(start, start + size); + + return { + success: true, + data: { + items: paginatedItems, + total: filtered.length, + page, + size, + totalPages: Math.ceil(filtered.length / size), + }, + }; + } catch (error) { + console.error('getPartnerList error:', error); + return { success: false, error: '거래처 목록 조회에 실패했습니다.' }; + } +} + +// 거래처 통계 조회 +export async function getPartnerStats(): Promise<{ success: boolean; data?: PartnerStats; error?: string }> { + try { + // TODO: 실제 API 호출 + const total = mockPartners.length; + const badDebt = mockPartners.filter((p) => p.isBadDebt).length; + + return { + success: true, + data: { + total, + unregistered: 5, // 목업 + badDebt, + normal: total - badDebt, + }, + }; + } catch (error) { + console.error('getPartnerStats error:', error); + return { success: false, error: '통계 조회에 실패했습니다.' }; + } +} + +// 거래처 삭제 +export async function deletePartner(id: string): Promise<{ success: boolean; error?: string }> { + try { + // TODO: 실제 API 호출 + console.log('Delete partner:', id); + return { success: true }; + } catch (error) { + console.error('deletePartner error:', error); + return { success: false, error: '거래처 삭제에 실패했습니다.' }; + } +} + +// 거래처 일괄 삭제 +export async function deletePartners(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> { + try { + // TODO: 실제 API 호출 + console.log('Delete partners:', ids); + return { success: true, deletedCount: ids.length }; + } catch (error) { + console.error('deletePartners error:', error); + return { success: false, error: '일괄 삭제에 실패했습니다.' }; + } +} \ No newline at end of file diff --git a/src/components/business/juil/partners/index.ts b/src/components/business/juil/partners/index.ts new file mode 100644 index 00000000..851df775 --- /dev/null +++ b/src/components/business/juil/partners/index.ts @@ -0,0 +1,3 @@ +export { default as PartnerListClient } from './PartnerListClient'; +export * from './types'; +export * from './actions'; \ No newline at end of file diff --git a/src/components/business/juil/partners/types.ts b/src/components/business/juil/partners/types.ts new file mode 100644 index 00000000..59a309a6 --- /dev/null +++ b/src/components/business/juil/partners/types.ts @@ -0,0 +1,57 @@ +/** + * 주일 기업 - 거래처 관리 타입 정의 + */ + +// 거래처 타입 +export interface Partner { + id: string; + partnerCode: string; // 거래처번호 + category: string; // 구분 (건설사, 시공사 등) + partnerName: string; // 거래처명 + representative: string; // 대표자 + manager: string; // 담당자 + phone: string; // 전화번호 + paymentDay: number | null; // 매출 결제일 + isBadDebt: boolean; // 악성채권 여부 + isActive: boolean; // 활성 상태 + createdAt: string; + updatedAt: string; +} + +// 거래처 통계 +export interface PartnerStats { + total: number; // 전체 거래처 + unregistered: number; // 미등록 + badDebt: number; // 악성채권 + normal: number; // 정상 +} + +// 거래처 필터 +export interface PartnerFilter { + search?: string; + badDebtFilter?: 'all' | 'badDebt' | 'normal'; + sortBy?: 'latest' | 'oldest' | 'nameAsc' | 'nameDesc'; + page?: number; + size?: number; +} + +// 거래처 폼 데이터 +export interface PartnerFormData { + partnerCode?: string; + category: string; + partnerName: string; + representative: string; + manager: string; + phone: string; + paymentDay: number | null; + isBadDebt: boolean; +} + +// API 응답 타입 +export interface PartnerListResponse { + items: Partner[]; + total: number; + page: number; + size: number; + totalPages: number; +} \ No newline at end of file diff --git a/src/components/customer-center/shared/actions.ts b/src/components/customer-center/shared/actions.ts index abed5f59..eca398d4 100644 --- a/src/components/customer-center/shared/actions.ts +++ b/src/components/customer-center/shared/actions.ts @@ -5,7 +5,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { PostApiData, PostPaginationResponse, @@ -16,31 +16,14 @@ import type { CommentsApiResponse, } from './types'; -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - /** * 게시글 목록 조회 */ export async function getPosts( boardCode: SystemBoardCode, filters?: PostFilters -): Promise<{ success: boolean; data?: PostPaginationResponse; error?: string }> { +): Promise<{ success: boolean; data?: PostPaginationResponse; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const params = new URLSearchParams(); if (filters?.search) params.append('search', filters.search); if (filters?.is_notice !== undefined) params.append('is_notice', String(filters.is_notice)); @@ -51,14 +34,16 @@ export async function getPosts( const queryString = params.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[CustomerCenterActions] GET posts error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시글 목록 조회에 실패했습니다.' }; } @@ -70,7 +55,7 @@ export async function getPosts( return { success: false, error: '서버 응답 형식 오류입니다.' }; } - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, error: result.message || '게시글 목록 조회에 실패했습니다.' }; } @@ -87,26 +72,26 @@ export async function getPosts( export async function getPost( boardCode: SystemBoardCode, postId: number | string -): Promise<{ success: boolean; data?: PostApiData; error?: string }> { +): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[CustomerCenterActions] GET post error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '게시글 조회에 실패했습니다.' }; } const result: ApiResponse = await response.json(); - if (!result.success || !result.data) { + if (!response.ok || !result.success || !result.data) { return { success: false, error: result.message || '게시글을 찾을 수 없습니다.' }; } @@ -128,25 +113,27 @@ export async function createPost( is_secret?: boolean; custom_fields?: Record; } -): Promise<{ success: boolean; data?: PostApiData; error?: string }> { +): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'POST', - headers, body: JSON.stringify(data), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시글 등록에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '게시글 등록에 실패했습니다.', - }; + return { success: false, error: result.message || '게시글 등록에 실패했습니다.' }; } return { success: true, data: result.data }; @@ -168,25 +155,27 @@ export async function updatePost( is_secret?: boolean; custom_fields?: Record; } -): Promise<{ success: boolean; data?: PostApiData; error?: string }> { +): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'PUT', - headers, body: JSON.stringify(data), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시글 수정에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '게시글 수정에 실패했습니다.', - }; + return { success: false, error: result.message || '게시글 수정에 실패했습니다.' }; } return { success: true, data: result.data }; @@ -202,24 +191,26 @@ export async function updatePost( export async function deletePost( boardCode: SystemBoardCode, postId: number | string -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'DELETE', - headers, }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '게시글 삭제에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '게시글 삭제에 실패했습니다.', - }; + return { success: false, error: result.message || '게시글 삭제에 실패했습니다.' }; } return { success: true }; @@ -237,26 +228,26 @@ export async function deletePost( export async function getComments( boardCode: SystemBoardCode, postId: number | string -): Promise<{ success: boolean; data?: CommentsApiResponse; error?: string }> { +): Promise<{ success: boolean; data?: CommentsApiResponse; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}/comments`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.error('[CustomerCenterActions] GET comments error:', response.status); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { return { success: false, error: '댓글 목록 조회에 실패했습니다.' }; } const result: ApiResponse = await response.json(); - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, error: result.message || '댓글 목록 조회에 실패했습니다.' }; } @@ -274,25 +265,27 @@ export async function createComment( boardCode: SystemBoardCode, postId: number | string, content: string -): Promise<{ success: boolean; data?: CommentApiData; error?: string }> { +): Promise<{ success: boolean; data?: CommentApiData; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}/comments`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'POST', - headers, body: JSON.stringify({ content }), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '댓글 등록에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '댓글 등록에 실패했습니다.', - }; + return { success: false, error: result.message || '댓글 등록에 실패했습니다.' }; } return { success: true, data: result.data }; @@ -310,25 +303,27 @@ export async function updateComment( postId: number | string, commentId: number | string, content: string -): Promise<{ success: boolean; data?: CommentApiData; error?: string }> { +): Promise<{ success: boolean; data?: CommentApiData; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}/comments/${commentId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'PUT', - headers, body: JSON.stringify({ content }), }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '댓글 수정에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '댓글 수정에 실패했습니다.', - }; + return { success: false, error: result.message || '댓글 수정에 실패했습니다.' }; } return { success: true, data: result.data }; @@ -345,24 +340,26 @@ export async function deleteComment( boardCode: SystemBoardCode, postId: number | string, commentId: number | string -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/system-boards/${boardCode}/posts/${postId}/comments/${commentId}`; - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'DELETE', - headers, }); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '댓글 삭제에 실패했습니다.' }; + } + const result = await response.json(); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '댓글 삭제에 실패했습니다.', - }; + return { success: false, error: result.message || '댓글 삭제에 실패했습니다.' }; } return { success: true }; @@ -370,4 +367,4 @@ export async function deleteComment( console.error('[CustomerCenterActions] deleteComment error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } -} +} \ No newline at end of file diff --git a/src/components/hr/AttendanceManagement/actions.ts b/src/components/hr/AttendanceManagement/actions.ts index 882fb611..5ae3c2ef 100644 --- a/src/components/hr/AttendanceManagement/actions.ts +++ b/src/components/hr/AttendanceManagement/actions.ts @@ -13,7 +13,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { AttendanceRecord, AttendanceApiData, @@ -44,20 +44,8 @@ interface PaginatedResponse { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} +// API URL +const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`; /** * API 응답 데이터를 프론트엔드 형식으로 변환 @@ -172,45 +160,35 @@ interface EmployeeApiData { * 사원 목록 조회 (근태 등록용) */ export async function getEmployeesForAttendance(): Promise { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); - searchParams.set('per_page', '100'); // API 최대 500, 드롭다운용 100 충분 - searchParams.set('status', 'active'); // 재직자만 + const searchParams = new URLSearchParams(); + searchParams.set('per_page', '100'); // API 최대 500, 드롭다운용 100 충분 + searchParams.set('status', 'active'); // 재직자만 - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees?${searchParams.toString()}`; + const url = `${API_URL}/v1/employees?${searchParams.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[AttendanceActions] GET employees error:', response.status); - return []; - } - - const result: ApiResponse> = await response.json(); - - if (!result.success || !result.data?.data) { - console.warn('[AttendanceActions] No employees data'); - return []; - } - - // API는 TenantUserProfile을 반환하지만, Attendance.user_id는 User.id를 참조 - // 따라서 user.id를 사용해야 함 - return result.data.data.map((emp) => ({ - id: String(emp.user?.id || emp.user_id), // User.id 사용 - name: emp.user?.name || emp.name, - department: emp.department?.name || emp.tenant_user_profile?.department?.name || '', - position: emp.position_key || emp.tenant_user_profile?.position?.name || '', - rank: emp.tenant_user_profile?.rank || '', - })); - } catch (error) { - console.error('[AttendanceActions] getEmployeesForAttendance error:', error); + if (error || !response) { + console.error('[AttendanceActions] GET employees error:', error?.message); return []; } + + const result: ApiResponse> = await response.json(); + + if (!result.success || !result.data?.data) { + console.warn('[AttendanceActions] No employees data'); + return []; + } + + // API는 TenantUserProfile을 반환하지만, Attendance.user_id는 User.id를 참조 + // 따라서 user.id를 사용해야 함 + return result.data.data.map((emp) => ({ + id: String(emp.user?.id || emp.user_id), // User.id 사용 + name: emp.user?.name || emp.name, + department: emp.department?.name || emp.tenant_user_profile?.department?.name || '', + position: emp.position_key || emp.tenant_user_profile?.position?.name || '', + rank: emp.tenant_user_profile?.rank || '', + })); } // ============================================ @@ -232,86 +210,62 @@ export async function getAttendances(params?: { sort_by?: string; sort_dir?: 'asc' | 'desc'; }): Promise<{ data: AttendanceRecord[]; total: number; lastPage: number }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.per_page) searchParams.set('per_page', String(params.per_page)); - if (params?.user_id) searchParams.set('user_id', params.user_id); - if (params?.date) searchParams.set('date', params.date); - if (params?.date_from) searchParams.set('date_from', params.date_from); - if (params?.date_to) searchParams.set('date_to', params.date_to); - if (params?.status && params.status !== 'all') { - searchParams.set('status', params.status); - } - if (params?.department_id) searchParams.set('department_id', params.department_id); - if (params?.sort_by) searchParams.set('sort_by', params.sort_by); - if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.per_page) searchParams.set('per_page', String(params.per_page)); + if (params?.user_id) searchParams.set('user_id', params.user_id); + if (params?.date) searchParams.set('date', params.date); + if (params?.date_from) searchParams.set('date_from', params.date_from); + if (params?.date_to) searchParams.set('date_to', params.date_to); + if (params?.status && params.status !== 'all') { + searchParams.set('status', params.status); + } + if (params?.department_id) searchParams.set('department_id', params.department_id); + if (params?.sort_by) searchParams.set('sort_by', params.sort_by); + if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances?${searchParams.toString()}`; + const url = `${API_URL}/v1/attendances?${searchParams.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[AttendanceActions] GET list error:', response.status); - return { data: [], total: 0, lastPage: 1 }; - } - - const result: ApiResponse> = await response.json(); - - if (!result.success || !result.data?.data) { - console.warn('[AttendanceActions] No data in response'); - return { data: [], total: 0, lastPage: 1 }; - } - - return { - data: result.data.data.map(transformApiToFrontend), - total: result.data.total, - lastPage: result.data.last_page, - }; - } catch (error) { - console.error('[AttendanceActions] getAttendances error:', error); + if (error || !response) { + console.error('[AttendanceActions] GET list error:', error?.message); return { data: [], total: 0, lastPage: 1 }; } + + const result: ApiResponse> = await response.json(); + + if (!result.success || !result.data?.data) { + console.warn('[AttendanceActions] No data in response'); + return { data: [], total: 0, lastPage: 1 }; + } + + return { + data: result.data.data.map(transformApiToFrontend), + total: result.data.total, + lastPage: result.data.last_page, + }; } /** * 근태 상세 조회 */ export async function getAttendanceById(id: string): Promise { - try { - const headers = await getApiHeaders(); + const { response, error } = await serverFetch(`${API_URL}/v1/attendances/${id}`, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[AttendanceActions] GET attendance error:', response.status); - return null; - } - - const result: ApiResponse = await response.json(); - - if (!result.success || !result.data) { - return null; - } - - return transformApiToFrontend(result.data); - } catch (error) { - console.error('[AttendanceActions] getAttendanceById error:', error); + if (error || !response) { + console.error('[AttendanceActions] GET attendance error:', error?.message); return null; } + + const result: ApiResponse = await response.json(); + + if (!result.success || !result.data) { + return null; + } + + return transformApiToFrontend(result.data); } /** @@ -320,42 +274,32 @@ export async function getAttendanceById(id: string): Promise { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[AttendanceActions] POST attendance request:', apiData); - console.log('[AttendanceActions] POST attendance request:', apiData); + const { response, error } = await serverFetch(`${API_URL}/v1/attendances`, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); + if (error || !response) { + return { success: false, error: error?.message || '근태 등록에 실패했습니다.' }; + } - const result = await response.json(); - console.log('[AttendanceActions] POST attendance response:', result); + const result = await response.json(); + console.log('[AttendanceActions] POST attendance response:', result); - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '근태 등록에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[AttendanceActions] createAttendance error:', error); + if (!result.success) { return { success: false, - error: '서버 오류가 발생했습니다.', + error: result.message || '근태 등록에 실패했습니다.', }; } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** @@ -365,113 +309,81 @@ export async function updateAttendance( id: string, data: AttendanceFormData ): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + console.log('[AttendanceActions] PATCH attendance request:', apiData); - console.log('[AttendanceActions] PATCH attendance request:', apiData); + const { response, error } = await serverFetch(`${API_URL}/v1/attendances/${id}`, { + method: 'PATCH', + body: JSON.stringify(apiData), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/${id}`, - { - method: 'PATCH', - headers, - body: JSON.stringify(apiData), - } - ); + if (error || !response) { + return { success: false, error: error?.message || '근태 수정에 실패했습니다.' }; + } - const result = await response.json(); - console.log('[AttendanceActions] PATCH attendance response:', result); + const result = await response.json(); + console.log('[AttendanceActions] PATCH attendance response:', result); - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '근태 수정에 실패했습니다.', - }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[AttendanceActions] updateAttendance error:', error); + if (!result.success) { return { success: false, - error: '서버 오류가 발생했습니다.', + error: result.message || '근태 수정에 실패했습니다.', }; } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** * 근태 삭제 */ export async function deleteAttendance(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const { response, error } = await serverFetch(`${API_URL}/v1/attendances/${id}`, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/${id}`, - { - method: 'DELETE', - headers, - } - ); + if (error || !response) { + return { success: false, error: error?.message || '근태 삭제에 실패했습니다.' }; + } - const result = await response.json(); - console.log('[AttendanceActions] DELETE attendance response:', result); + const result = await response.json(); + console.log('[AttendanceActions] DELETE attendance response:', result); - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '근태 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[AttendanceActions] deleteAttendance error:', error); + if (!result.success) { return { success: false, - error: '서버 오류가 발생했습니다.', + error: result.message || '근태 삭제에 실패했습니다.', }; } + + return { success: true }; } /** * 근태 일괄 삭제 */ export async function deleteAttendances(ids: string[]): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); + const { response, error } = await serverFetch(`${API_URL}/v1/attendances/bulk-delete`, { + method: 'POST', + body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)) }), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/bulk-delete`, - { - method: 'POST', - headers, - body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)) }), - } - ); + if (error || !response) { + return { success: false, error: error?.message || '근태 일괄 삭제에 실패했습니다.' }; + } - const result = await response.json(); - console.log('[AttendanceActions] BULK DELETE attendance response:', result); + const result = await response.json(); + console.log('[AttendanceActions] BULK DELETE attendance response:', result); - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '근태 일괄 삭제에 실패했습니다.', - }; - } - - return { success: true }; - } catch (error) { - console.error('[AttendanceActions] deleteAttendances error:', error); + if (!result.success) { return { success: false, - error: '서버 오류가 발생했습니다.', + error: result.message || '근태 일괄 삭제에 실패했습니다.', }; } + + return { success: true }; } /** @@ -482,43 +394,33 @@ export async function getMonthlyStats(params: { month: number; user_id?: string; }): Promise { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - searchParams.set('year', String(params.year)); - searchParams.set('month', String(params.month)); - if (params.user_id) searchParams.set('user_id', params.user_id); + searchParams.set('year', String(params.year)); + searchParams.set('month', String(params.month)); + if (params.user_id) searchParams.set('user_id', params.user_id); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/monthly-stats?${searchParams.toString()}`; + const url = `${API_URL}/v1/attendances/monthly-stats?${searchParams.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[AttendanceActions] GET monthly-stats error:', response.status); - return null; - } - - const result = await response.json(); - - if (!result.success || !result.data) { - return null; - } - - return { - year: result.data.year, - month: result.data.month, - totalDays: result.data.total_days, - byStatus: result.data.by_status, - totalWorkMinutes: result.data.total_work_minutes, - totalOvertimeMinutes: result.data.total_overtime_minutes, - }; - } catch (error) { - console.error('[AttendanceActions] getMonthlyStats error:', error); + if (error || !response) { + console.error('[AttendanceActions] GET monthly-stats error:', error?.message); return null; } + + const result = await response.json(); + + if (!result.success || !result.data) { + return null; + } + + return { + year: result.data.year, + month: result.data.month, + totalDays: result.data.total_days, + byStatus: result.data.by_status, + totalWorkMinutes: result.data.total_work_minutes, + totalOvertimeMinutes: result.data.total_overtime_minutes, + }; } diff --git a/src/components/hr/CardManagement/actions.ts b/src/components/hr/CardManagement/actions.ts index 664c3612..df9a562b 100644 --- a/src/components/hr/CardManagement/actions.ts +++ b/src/components/hr/CardManagement/actions.ts @@ -1,6 +1,6 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { Card, CardFormData, CardStatus } from './types'; // API 응답 타입 @@ -54,19 +54,6 @@ interface CardResponse { data: CardApiData; } -// API 헤더 생성 -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // API URL (without double /api) const API_URL = `${process.env.NEXT_PUBLIC_API_URL || process.env.API_URL}/api`; @@ -147,153 +134,128 @@ export async function getCards(params?: { page?: number; per_page?: number; }): Promise<{ success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.search) searchParams.set('search', params.search); - if (params?.status && params.status !== 'all') searchParams.set('status', mapFrontendStatusToApi(params.status as CardStatus)); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.per_page) searchParams.set('per_page', String(params.per_page)); + if (params?.search) searchParams.set('search', params.search); + if (params?.status && params.status !== 'all') searchParams.set('status', mapFrontendStatusToApi(params.status as CardStatus)); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.per_page) searchParams.set('per_page', String(params.per_page)); - const url = `${API_URL}/v1/cards${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const url = `${API_URL}/v1/cards${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const result: CardListResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '카드 목록을 불러오는데 실패했습니다.' }; - } - - return { - success: true, - data: result.data.data.map(transformApiToFrontend), - pagination: { - total: result.data.total, - currentPage: result.data.current_page, - lastPage: result.data.last_page, - }, - }; - } catch (error) { - console.error('[getCards] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '카드 목록을 불러오는데 실패했습니다.' }; } + + const result: CardListResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '카드 목록을 불러오는데 실패했습니다.' }; + } + + return { + success: true, + data: result.data.data.map(transformApiToFrontend), + pagination: { + total: result.data.total, + currentPage: result.data.current_page, + lastPage: result.data.last_page, + }, + }; } /** * 카드 상세 조회 */ export async function getCard(id: string): Promise<{ success: boolean; data?: Card; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/cards/${id}`, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(`${API_URL}/v1/cards/${id}`, { method: 'GET' }); - const result: CardResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '카드 정보를 불러오는데 실패했습니다.' }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[getCard] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '카드 정보를 불러오는데 실패했습니다.' }; } + + const result: CardResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '카드 정보를 불러오는데 실패했습니다.' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** * 카드 등록 */ export async function createCard(data: CardFormData): Promise<{ success: boolean; data?: Card; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + const { response, error } = await serverFetch(`${API_URL}/v1/cards`, { + method: 'POST', + body: JSON.stringify(apiData), + }); - const response = await fetch(`${API_URL}/v1/cards`, { - method: 'POST', - headers, - body: JSON.stringify(apiData), - }); - - const result: CardResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '카드 등록에 실패했습니다.' }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[createCard] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '카드 등록에 실패했습니다.' }; } + + const result: CardResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '카드 등록에 실패했습니다.' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** * 카드 수정 */ export async function updateCard(id: string, data: CardFormData): Promise<{ success: boolean; data?: Card; error?: string }> { - try { - const headers = await getApiHeaders(); - const apiData = transformFrontendToApi(data); + const apiData = transformFrontendToApi(data); + const { response, error } = await serverFetch(`${API_URL}/v1/cards/${id}`, { + method: 'PUT', + body: JSON.stringify(apiData), + }); - const response = await fetch(`${API_URL}/v1/cards/${id}`, { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - }); - - const result: CardResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '카드 수정에 실패했습니다.' }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[updateCard] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '카드 수정에 실패했습니다.' }; } + + const result: CardResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '카드 수정에 실패했습니다.' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** * 카드 삭제 */ export async function deleteCard(id: string): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/cards/${id}`, { - method: 'DELETE', - headers, - }); + const { response, error } = await serverFetch(`${API_URL}/v1/cards/${id}`, { method: 'DELETE' }); - const result = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '카드 삭제에 실패했습니다.' }; - } - - return { success: true }; - } catch (error) { - console.error('[deleteCard] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '카드 삭제에 실패했습니다.' }; } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '카드 삭제에 실패했습니다.' }; + } + + return { success: true }; } /** @@ -319,27 +281,22 @@ export async function deleteCards(ids: string[]): Promise<{ success: boolean; er * 카드 상태 토글 */ export async function toggleCardStatus(id: string): Promise<{ success: boolean; data?: Card; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/cards/${id}/toggle`, { - method: 'PATCH', - headers, - }); + const { response, error } = await serverFetch(`${API_URL}/v1/cards/${id}/toggle`, { method: 'PATCH' }); - const result: CardResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '상태 변경에 실패했습니다.' }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[toggleCardStatus] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '상태 변경에 실패했습니다.' }; } + + const result: CardResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '상태 변경에 실패했습니다.' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** @@ -347,36 +304,30 @@ export async function toggleCardStatus(id: string): Promise<{ success: boolean; * 주의: Card.assigned_user_id는 User.id를 참조하므로 user.id를 반환해야 함 */ export async function getActiveEmployees(): Promise<{ success: boolean; data?: Array<{ id: string; label: string }>; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/employees?status=active&per_page=50`, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(`${API_URL}/v1/employees?status=active&per_page=50`, { method: 'GET' }); - const result = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '직원 목록을 불러오는데 실패했습니다.' }; - } - - // API는 TenantUserProfile을 반환하지만, Card.assigned_user_id는 User.id를 참조 - // 따라서 user.id를 사용해야 함 - const employees = result.data.data.map((emp: { - id: number; - user_id: number; - user?: { id: number; name: string }; - department?: { name: string }; - position_key?: string; - }) => ({ - id: String(emp.user?.id || emp.user_id), // User.id 사용 - label: `${emp.department?.name || ''} / ${emp.user?.name || ''} / ${emp.position_key || ''}`.replace(/^ \/ | \/ $/g, '').replace(/ \/ $/g, ''), - })); - - return { success: true, data: employees }; - } catch (error) { - console.error('[getActiveEmployees] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '직원 목록을 불러오는데 실패했습니다.' }; } + + const result = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '직원 목록을 불러오는데 실패했습니다.' }; + } + + // API는 TenantUserProfile을 반환하지만, Card.assigned_user_id는 User.id를 참조 + // 따라서 user.id를 사용해야 함 + const employees = result.data.data.map((emp: { + id: number; + user_id: number; + user?: { id: number; name: string }; + department?: { name: string }; + position_key?: string; + }) => ({ + id: String(emp.user?.id || emp.user_id), // User.id 사용 + label: `${emp.department?.name || ''} / ${emp.user?.name || ''} / ${emp.position_key || ''}`.replace(/^ \/ | \/ $/g, '').replace(/ \/ $/g, ''), + })); + + return { success: true, data: employees }; } \ No newline at end of file diff --git a/src/components/hr/DepartmentManagement/actions.ts b/src/components/hr/DepartmentManagement/actions.ts index 5c9c4ef6..aa288242 100644 --- a/src/components/hr/DepartmentManagement/actions.ts +++ b/src/components/hr/DepartmentManagement/actions.ts @@ -12,7 +12,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; // ============================================ // 타입 정의 @@ -91,20 +91,8 @@ interface ApiResponse { // 헬퍼 함수 // ============================================ -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} +// API URL +const API_URL = `${process.env.NEXT_PUBLIC_API_URL}/api`; /** * API 응답을 프론트엔드 형식으로 변환 (재귀) @@ -141,43 +129,35 @@ function transformApiToFrontend(apiData: ApiDepartment, depth: number = 0): Depa export async function getDepartmentTree(params?: { withUsers?: boolean; }): Promise<{ success: boolean; data?: DepartmentRecord[]; error?: string }> { - try { - const headers = await getApiHeaders(); - const queryParams = new URLSearchParams(); + const queryParams = new URLSearchParams(); - if (params?.withUsers) { - queryParams.append('with_users', '1'); - } + if (params?.withUsers) { + queryParams.append('with_users', '1'); + } - const queryString = queryParams.toString(); - const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/tree${queryString ? `?${queryString}` : ''}`; + const queryString = queryParams.toString(); + const url = `${API_URL}/v1/departments/tree${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - const result: ApiResponse = await response.json(); + if (error || !response) { + return { success: false, error: error?.message || '부서 트리 조회에 실패했습니다.' }; + } - if (result.success && result.data) { - const transformed = result.data.map((dept) => transformApiToFrontend(dept, 0)); - return { - success: true, - data: transformed, - }; - } + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + const transformed = result.data.map((dept) => transformApiToFrontend(dept, 0)); return { - success: false, - error: result.message || '부서 트리 조회에 실패했습니다.', - }; - } catch (error) { - console.error('[getDepartmentTree] Error:', error); - return { - success: false, - error: error instanceof Error ? error.message : '부서 트리 조회에 실패했습니다.', + success: true, + data: transformed, }; } + + return { + success: false, + error: result.message || '부서 트리 조회에 실패했습니다.', + }; } /** @@ -187,33 +167,25 @@ export async function getDepartmentTree(params?: { export async function getDepartmentById( id: number ): Promise<{ success: boolean; data?: DepartmentRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/${id}`, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, { method: 'GET' }); - const result: ApiResponse = await response.json(); + if (error || !response) { + return { success: false, error: error?.message || '부서 조회에 실패했습니다.' }; + } - if (result.success && result.data) { - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } + const result: ApiResponse = await response.json(); + if (result.success && result.data) { return { - success: false, - error: result.message || '부서 조회에 실패했습니다.', - }; - } catch (error) { - console.error('[getDepartmentById] Error:', error); - return { - success: false, - error: error instanceof Error ? error.message : '부서 조회에 실패했습니다.', + success: true, + data: transformApiToFrontend(result.data), }; } + + return { + success: false, + error: result.message || '부서 조회에 실패했습니다.', + }; } /** @@ -223,41 +195,35 @@ export async function getDepartmentById( export async function createDepartment( data: CreateDepartmentRequest ): Promise<{ success: boolean; data?: DepartmentRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments`, { - method: 'POST', - headers, - body: JSON.stringify({ - parent_id: data.parentId, - code: data.code, - name: data.name, - description: data.description, - is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined, - sort_order: data.sortOrder, - }), - }); + const { response, error } = await serverFetch(`${API_URL}/v1/departments`, { + method: 'POST', + body: JSON.stringify({ + parent_id: data.parentId, + code: data.code, + name: data.name, + description: data.description, + is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined, + sort_order: data.sortOrder, + }), + }); - const result: ApiResponse = await response.json(); + if (error || !response) { + return { success: false, error: error?.message || '부서 생성에 실패했습니다.' }; + } - if (result.success && result.data) { - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } + const result: ApiResponse = await response.json(); + if (result.success && result.data) { return { - success: false, - error: result.message || '부서 생성에 실패했습니다.', - }; - } catch (error) { - console.error('[createDepartment] Error:', error); - return { - success: false, - error: error instanceof Error ? error.message : '부서 생성에 실패했습니다.', + success: true, + data: transformApiToFrontend(result.data), }; } + + return { + success: false, + error: result.message || '부서 생성에 실패했습니다.', + }; } /** @@ -268,41 +234,35 @@ export async function updateDepartment( id: number, data: UpdateDepartmentRequest ): Promise<{ success: boolean; data?: DepartmentRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/${id}`, { - method: 'PATCH', - headers, - body: JSON.stringify({ - parent_id: data.parentId === null ? 0 : data.parentId, // null이면 0(최상위)으로 전환 - code: data.code, - name: data.name, - description: data.description, - is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined, - sort_order: data.sortOrder, - }), - }); + const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, { + method: 'PATCH', + body: JSON.stringify({ + parent_id: data.parentId === null ? 0 : data.parentId, // null이면 0(최상위)으로 전환 + code: data.code, + name: data.name, + description: data.description, + is_active: data.isActive !== undefined ? (data.isActive ? 1 : 0) : undefined, + sort_order: data.sortOrder, + }), + }); - const result: ApiResponse = await response.json(); + if (error || !response) { + return { success: false, error: error?.message || '부서 수정에 실패했습니다.' }; + } - if (result.success && result.data) { - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } + const result: ApiResponse = await response.json(); + if (result.success && result.data) { return { - success: false, - error: result.message || '부서 수정에 실패했습니다.', - }; - } catch (error) { - console.error('[updateDepartment] Error:', error); - return { - success: false, - error: error instanceof Error ? error.message : '부서 수정에 실패했습니다.', + success: true, + data: transformApiToFrontend(result.data), }; } + + return { + success: false, + error: result.message || '부서 수정에 실패했습니다.', + }; } /** @@ -312,30 +272,22 @@ export async function updateDepartment( export async function deleteDepartment( id: number ): Promise<{ success: boolean; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/${id}`, { - method: 'DELETE', - headers, - }); + const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, { method: 'DELETE' }); - const result: ApiResponse<{ id: number; deleted_at: string }> = await response.json(); - - if (result.success) { - return { success: true }; - } - - return { - success: false, - error: result.message || '부서 삭제에 실패했습니다.', - }; - } catch (error) { - console.error('[deleteDepartment] Error:', error); - return { - success: false, - error: error instanceof Error ? error.message : '부서 삭제에 실패했습니다.', - }; + if (error || !response) { + return { success: false, error: error?.message || '부서 삭제에 실패했습니다.' }; } + + const result: ApiResponse<{ id: number; deleted_at: string }> = await response.json(); + + if (result.success) { + return { success: true }; + } + + return { + success: false, + error: result.message || '부서 삭제에 실패했습니다.', + }; } /** diff --git a/src/components/hr/EmployeeManagement/EmployeeForm.tsx b/src/components/hr/EmployeeManagement/EmployeeForm.tsx index 6e5207e1..4a82835d 100644 --- a/src/components/hr/EmployeeManagement/EmployeeForm.tsx +++ b/src/components/hr/EmployeeManagement/EmployeeForm.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useDaumPostcode } from '@/hooks/useDaumPostcode'; -import { useRouter } from 'next/navigation'; +import { useRouter, useParams } from 'next/navigation'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -16,7 +16,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Users, Plus, Trash2, ArrowLeft, Save, Settings, Camera } from 'lucide-react'; +import { Users, Plus, Trash2, ArrowLeft, Save, Settings, Camera, Edit } from 'lucide-react'; +import { toast } from 'sonner'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { FieldSettingsDialog } from './FieldSettingsDialog'; import type { @@ -36,12 +37,23 @@ import { import { uploadProfileImage } from './actions'; interface EmployeeFormProps { - mode: 'create' | 'edit'; + mode: 'create' | 'edit' | 'view'; employee?: Employee; - onSave: (data: EmployeeFormData) => void; + onSave?: (data: EmployeeFormData) => void; + onEdit?: () => void; + onDelete?: () => void; fieldSettings?: FieldSettings; } +// 유효성 검사 에러 타입 +interface ValidationErrors { + name?: string; + email?: string; + userId?: string; + password?: string; + confirmPassword?: string; +} + const initialFormData: EmployeeFormData = { name: '', residentNumber: '', @@ -74,10 +86,16 @@ export function EmployeeForm({ mode, employee, onSave, + onEdit, + onDelete, fieldSettings: initialFieldSettings = DEFAULT_FIELD_SETTINGS, }: EmployeeFormProps) { const router = useRouter(); + const params = useParams(); + const locale = params.locale as string || 'ko'; const [formData, setFormData] = useState(initialFormData); + const [errors, setErrors] = useState({}); + const isViewMode = mode === 'view'; // Daum 우편번호 서비스 const { openPostcode } = useDaumPostcode({ @@ -97,7 +115,12 @@ export function EmployeeForm({ const [showFieldSettings, setShowFieldSettings] = useState(false); const [fieldSettings, setFieldSettings] = useState(initialFieldSettings); - const title = mode === 'create' ? '사원 등록' : '사원 수정'; + const title = mode === 'create' ? '사원 등록' : mode === 'edit' ? '사원 수정' : '사원 상세'; + const description = mode === 'create' + ? '새로운 사원 정보를 입력합니다' + : mode === 'edit' + ? '사원 정보를 수정합니다' + : '사원 정보를 확인합니다'; // localStorage에서 항목 설정 로드 useEffect(() => { @@ -117,9 +140,9 @@ export function EmployeeForm({ localStorage.setItem('employeeFieldSettings', JSON.stringify(newSettings)); }; - // 데이터 초기화 + // 데이터 초기화 (edit, view 모드) useEffect(() => { - if (employee && mode === 'edit') { + if (employee && (mode === 'edit' || mode === 'view')) { setFormData({ name: employee.name, residentNumber: employee.residentNumber || '', @@ -153,6 +176,62 @@ export function EmployeeForm({ // 입력 변경 핸들러 const handleChange = (field: keyof EmployeeFormData, value: unknown) => { setFormData(prev => ({ ...prev, [field]: value })); + // 에러 초기화 + if (errors[field as keyof ValidationErrors]) { + setErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + // 이메일 형식 검사 + const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + // 유효성 검사 + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {}; + + // 이름 필수 + if (!formData.name.trim()) { + newErrors.name = '이름을 입력해주세요.'; + } + + // 이메일 필수 + 형식 검사 + if (!formData.email.trim()) { + newErrors.email = '이메일을 입력해주세요.'; + } else if (!isValidEmail(formData.email)) { + newErrors.email = '올바른 이메일 형식이 아닙니다.'; + } + + // 아이디 필수 + if (!formData.userId.trim()) { + newErrors.userId = '아이디를 입력해주세요.'; + } + + // 등록 모드일 때 비밀번호 검사 + if (mode === 'create') { + if (!formData.password) { + newErrors.password = '비밀번호를 입력해주세요.'; + } else if (formData.password.length < 6) { + newErrors.password = '비밀번호는 6자 이상이어야 합니다.'; + } + + if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = '비밀번호가 일치하지 않습니다.'; + } + } + + setErrors(newErrors); + + // 에러가 있으면 첫 번째 에러 메시지 표시 + const firstError = Object.values(newErrors)[0]; + if (firstError) { + toast.error(firstError); + return false; + } + + return true; }; // 부서/직책 추가 @@ -191,12 +270,21 @@ export function EmployeeForm({ // 저장 const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - onSave(formData); + + // view 모드에서는 저장 불가 + if (isViewMode) return; + + // 유효성 검사 + if (!validateForm()) { + return; + } + + onSave?.(formData); }; - // 취소 + // 취소 (목록으로 이동) const handleCancel = () => { - router.back(); + router.push(`/${locale}/hr/employee-management`); }; return ( @@ -205,17 +293,19 @@ export function EmployeeForm({
- + {!isViewMode && ( + + )}
@@ -234,8 +324,10 @@ export function EmployeeForm({ value={formData.name} onChange={(e) => handleChange('name', e.target.value)} placeholder="이름을 입력하세요" - required + disabled={isViewMode} + className={errors.name ? 'border-red-500' : ''} /> + {errors.name &&

{errors.name}

}
@@ -245,6 +337,7 @@ export function EmployeeForm({ value={formData.residentNumber} onChange={(e) => handleChange('residentNumber', e.target.value)} placeholder="000000-0000000" + disabled={isViewMode} />
@@ -255,18 +348,22 @@ export function EmployeeForm({ value={formData.phone} onChange={(e) => handleChange('phone', e.target.value)} placeholder="010-0000-0000" + disabled={isViewMode} />
- + handleChange('email', e.target.value)} placeholder="email@company.com" + disabled={isViewMode} + className={errors.email ? 'border-red-500' : ''} /> + {errors.email &&

{errors.email}

}
@@ -277,6 +374,7 @@ export function EmployeeForm({ value={formData.salary} onChange={(e) => handleChange('salary', e.target.value)} placeholder="연봉 (원)" + disabled={isViewMode} />
@@ -289,16 +387,19 @@ export function EmployeeForm({ value={formData.bankAccount.bankName} onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })} placeholder="은행명" + disabled={isViewMode} /> handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })} placeholder="계좌번호" + disabled={isViewMode} /> handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })} placeholder="예금주" + disabled={isViewMode} /> @@ -318,32 +419,46 @@ export function EmployeeForm({ {fieldSettings.showProfileImage && (
-
- IMG -
- -
- { - const file = e.target.files?.[0]; - if (file) { - // 미리보기 즉시 표시 - handleChange('profileImage', URL.createObjectURL(file)); - // 서버에 업로드 - const result = await uploadProfileImage(file); - if (result.success && result.data?.url) { - handleChange('profileImage', result.data.url); +
+ {formData.profileImage ? ( + 프로필 + ) : ( + <> + IMG +
+ +
+ + )} + {!isViewMode && ( + { + const file = e.target.files?.[0]; + if (file) { + // 미리보기 즉시 표시 + handleChange('profileImage', URL.createObjectURL(file)); + // 서버에 업로드 + const result = await uploadProfileImage(file); + if (result.success && result.data?.url) { + handleChange('profileImage', result.data.url); + } } - } - }} - /> + }} + /> + )}
-

- 1 250 X 250px, 10MB 이하의
PNG, JPEG, GIF -

+ {!isViewMode && ( +

+ 1 250 X 250px, 10MB 이하의
PNG, JPEG, GIF +

+ )}
)} @@ -358,6 +473,7 @@ export function EmployeeForm({ value={formData.employeeCode} onChange={(e) => handleChange('employeeCode', e.target.value)} placeholder="사원코드를 입력해주세요" + disabled={isViewMode} />
)} @@ -369,13 +485,14 @@ export function EmployeeForm({ value={formData.gender} onValueChange={(value) => handleChange('gender', value)} className="flex items-center gap-4 h-10" + disabled={isViewMode} >
- +
- +
@@ -388,21 +505,25 @@ export function EmployeeForm({
- + {!isViewMode && ( + + )} handleChange('address', { ...formData.address, zipCode: e.target.value })} placeholder="" className="w-24" readOnly + disabled={isViewMode} /> handleChange('address', { ...formData.address, address2: e.target.value })} placeholder="상세주소를 입력해주세요" className="flex-1" + disabled={isViewMode} />
@@ -429,6 +550,7 @@ export function EmployeeForm({ type="date" value={formData.hireDate} onChange={(e) => handleChange('hireDate', e.target.value)} + disabled={isViewMode} /> )} @@ -439,8 +561,9 @@ export function EmployeeForm({ handleChange('status', value)} + disabled={isViewMode} > - + @@ -489,20 +614,22 @@ export function EmployeeForm({
- + {!isViewMode && ( + + )}
{formData.departmentPositions.length === 0 ? (

- 부서/직책을 추가해주세요 + {isViewMode ? '등록된 부서/직책이 없습니다' : '부서/직책을 추가해주세요'}

) : (
@@ -513,21 +640,25 @@ export function EmployeeForm({ onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)} placeholder="부서명" className="flex-1" + disabled={isViewMode} /> handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)} placeholder="직책" className="flex-1" + disabled={isViewMode} /> - + {!isViewMode && ( + + )}
))}
@@ -543,8 +674,9 @@ export function EmployeeForm({ handleChange('clockOutLocation', value)} + disabled={isViewMode} > - + @@ -589,6 +722,7 @@ export function EmployeeForm({ type="date" value={formData.resignationDate} onChange={(e) => handleChange('resignationDate', e.target.value)} + disabled={isViewMode} /> )} @@ -601,6 +735,7 @@ export function EmployeeForm({ value={formData.resignationReason} onChange={(e) => handleChange('resignationReason', e.target.value)} placeholder="퇴직 사유를 입력하세요" + disabled={isViewMode} /> )} @@ -625,7 +760,10 @@ export function EmployeeForm({ value={formData.userId} onChange={(e) => handleChange('userId', e.target.value)} placeholder="사용자 아이디" + disabled={isViewMode} + className={errors.userId ? 'border-red-500' : ''} /> + {errors.userId &&

{errors.userId}

} {mode === 'create' && ( @@ -638,18 +776,22 @@ export function EmployeeForm({ value={formData.password} onChange={(e) => handleChange('password', e.target.value)} placeholder="비밀번호" + className={errors.password ? 'border-red-500' : ''} /> + {errors.password &&

{errors.password}

}
- + handleChange('confirmPassword', e.target.value)} placeholder="비밀번호 확인" + className={errors.confirmPassword ? 'border-red-500' : ''} /> + {errors.confirmPassword &&

{errors.confirmPassword}

}
)} @@ -659,8 +801,9 @@ export function EmployeeForm({ handleChange('accountStatus', value)} + disabled={isViewMode} > - + @@ -695,12 +839,25 @@ export function EmployeeForm({
- + {isViewMode ? ( +
+ + +
+ ) : ( + + )}
diff --git a/src/components/hr/EmployeeManagement/actions.ts b/src/components/hr/EmployeeManagement/actions.ts index 189cf5ba..97ad49d3 100644 --- a/src/components/hr/EmployeeManagement/actions.ts +++ b/src/components/hr/EmployeeManagement/actions.ts @@ -9,11 +9,14 @@ * - DELETE /api/v1/employees/{id} - 삭제 * - POST /api/v1/employees/bulk-delete - 일괄 삭제 * - GET /api/v1/employees/stats - 통계 + * + * 🚨 401 에러 시 __authError: true 반환 → 클라이언트에서 로그인 페이지로 리다이렉트 */ 'use server'; import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { Employee, EmployeeFormData, EmployeeStats } from './types'; import { transformApiToFrontend, transformFrontendToApi, type EmployeeApiData } from './utils'; @@ -35,25 +38,6 @@ interface PaginatedResponse { last_page: number; } -// ============================================ -// 헬퍼 함수 -// ============================================ - -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ============================================ // API 함수 // ============================================ @@ -70,9 +54,8 @@ export async function getEmployees(params?: { has_account?: boolean; sort_by?: string; sort_dir?: 'asc' | 'desc'; -}): Promise<{ data: Employee[]; total: number; lastPage: number }> { +}): Promise<{ data: Employee[]; total: number; lastPage: number; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -90,14 +73,15 @@ export async function getEmployees(params?: { const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees?${searchParams.toString()}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); - if (!response.ok) { - console.error('[EmployeeActions] GET list error:', response.status); + // 🚨 401 인증 에러 → 클라이언트에서 로그인 페이지로 리다이렉트 + if (error?.__authError) { + return { data: [], total: 0, lastPage: 1, __authError: true }; + } + + if (!response || !response.ok) { + console.error('[EmployeeActions] GET list error:', response?.status); return { data: [], total: 0, lastPage: 1 }; } @@ -122,21 +106,18 @@ export async function getEmployees(params?: { /** * 직원 상세 조회 */ -export async function getEmployeeById(id: string): Promise { +export async function getEmployeeById(id: string): Promise { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); + // 🚨 401 인증 에러 + if (error?.__authError) { + return { __authError: true }; + } - if (!response.ok) { - console.error('[EmployeeActions] GET employee error:', response.status); + if (!response || !response.ok) { + console.error('[EmployeeActions] GET employee error:', response?.status); return null; } @@ -158,21 +139,26 @@ export async function getEmployeeById(id: string): Promise { */ export async function createEmployee( data: EmployeeFormData -): Promise<{ success: boolean; data?: Employee; error?: string }> { +): Promise<{ success: boolean; data?: Employee; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`; console.log('[EmployeeActions] POST employee request:', apiData); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } - ); + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); + + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '서버 오류가 발생했습니다.' }; + } const result = await response.json(); console.log('[EmployeeActions] POST employee response:', result); @@ -203,21 +189,26 @@ export async function createEmployee( export async function updateEmployee( id: string, data: EmployeeFormData -): Promise<{ success: boolean; data?: Employee; error?: string }> { +): Promise<{ success: boolean; data?: Employee; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`; console.log('[EmployeeActions] PATCH employee request:', apiData); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`, - { - method: 'PATCH', - headers, - body: JSON.stringify(apiData), - } - ); + const { response, error } = await serverFetch(url, { + method: 'PATCH', + body: JSON.stringify(apiData), + }); + + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '서버 오류가 발생했습니다.' }; + } const result = await response.json(); console.log('[EmployeeActions] PATCH employee response:', result); @@ -245,17 +236,19 @@ export async function updateEmployee( /** * 직원 삭제 (퇴직 처리) */ -export async function deleteEmployee(id: string): Promise<{ success: boolean; error?: string }> { +export async function deleteEmployee(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`, - { - method: 'DELETE', - headers, - } - ); + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '서버 오류가 발생했습니다.' }; + } const result = await response.json(); console.log('[EmployeeActions] DELETE employee response:', result); @@ -280,18 +273,22 @@ export async function deleteEmployee(id: string): Promise<{ success: boolean; er /** * 직원 일괄 삭제 */ -export async function deleteEmployees(ids: string[]): Promise<{ success: boolean; error?: string }> { +export async function deleteEmployees(ids: string[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/bulk-delete`; + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)) }), + }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/bulk-delete`, - { - method: 'POST', - headers, - body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)) }), - } - ); + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { success: false, error: error?.message || '서버 오류가 발생했습니다.' }; + } const result = await response.json(); console.log('[EmployeeActions] BULK DELETE employee response:', result); @@ -316,21 +313,18 @@ export async function deleteEmployees(ids: string[]): Promise<{ success: boolean /** * 직원 통계 조회 */ -export async function getEmployeeStats(): Promise { +export async function getEmployeeStats(): Promise { try { - const headers = await getApiHeaders(); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/stats`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/stats`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); + // 🚨 401 인증 에러 + if (error?.__authError) { + return { __authError: true }; + } - if (!response.ok) { - console.error('[EmployeeActions] GET stats error:', response.status); + if (!response || !response.ok) { + console.error('[EmployeeActions] GET stats error:', response?.status); return null; } @@ -359,11 +353,17 @@ export async function uploadProfileImage(file: File): Promise<{ success: boolean; data?: { url: string; path: string }; error?: string; + __authError?: boolean; }> { try { const cookieStore = await cookies(); const token = cookieStore.get('access_token')?.value; + // 토큰 없으면 인증 에러 + if (!token) { + return { success: false, __authError: true }; + } + const formData = new FormData(); formData.append('file', file); formData.append('directory', 'employees/profiles'); @@ -373,13 +373,18 @@ export async function uploadProfileImage(file: File): Promise<{ { method: 'POST', headers: { - 'Authorization': token ? `Bearer ${token}` : '', + 'Authorization': `Bearer ${token}`, 'X-API-KEY': process.env.API_KEY || '', }, body: formData, } ); + // 🚨 401 인증 에러 + if (response.status === 401) { + return { success: false, __authError: true }; + } + if (!response.ok) { return { success: false, error: `파일 업로드 실패: ${response.status}` }; } diff --git a/src/components/hr/SalaryManagement/actions.ts b/src/components/hr/SalaryManagement/actions.ts index f67d075e..1d06ce2d 100644 --- a/src/components/hr/SalaryManagement/actions.ts +++ b/src/components/hr/SalaryManagement/actions.ts @@ -1,6 +1,6 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types'; // API 응답 타입 @@ -85,19 +85,6 @@ interface BulkUpdateResponse { }; } -// API 헤더 생성 -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // API URL const API_URL = `${process.env.NEXT_PUBLIC_API_URL || process.env.API_URL}/api`; @@ -184,46 +171,40 @@ export async function getSalaries(params?: { pagination?: { total: number; currentPage: number; lastPage: number }; error?: string }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.search) searchParams.set('search', params.search); - if (params?.year) searchParams.set('year', String(params.year)); - if (params?.month) searchParams.set('month', String(params.month)); - if (params?.status && params.status !== 'all') searchParams.set('status', params.status); - if (params?.employee_id) searchParams.set('employee_id', String(params.employee_id)); - if (params?.start_date) searchParams.set('start_date', params.start_date); - if (params?.end_date) searchParams.set('end_date', params.end_date); - if (params?.page) searchParams.set('page', String(params.page)); - if (params?.per_page) searchParams.set('per_page', String(params.per_page)); + if (params?.search) searchParams.set('search', params.search); + if (params?.year) searchParams.set('year', String(params.year)); + if (params?.month) searchParams.set('month', String(params.month)); + if (params?.status && params.status !== 'all') searchParams.set('status', params.status); + if (params?.employee_id) searchParams.set('employee_id', String(params.employee_id)); + if (params?.start_date) searchParams.set('start_date', params.start_date); + if (params?.end_date) searchParams.set('end_date', params.end_date); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.per_page) searchParams.set('per_page', String(params.per_page)); - const url = `${API_URL}/v1/salaries${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const url = `${API_URL}/v1/salaries${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const result: SalaryListResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '급여 목록을 불러오는데 실패했습니다.' }; - } - - return { - success: true, - data: result.data.data.map(transformApiToFrontend), - pagination: { - total: result.data.total, - currentPage: result.data.current_page, - lastPage: result.data.last_page, - }, - }; - } catch (error) { - console.error('[getSalaries] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '급여 목록을 불러오는데 실패했습니다.' }; } + + const result: SalaryListResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '급여 목록을 불러오는데 실패했습니다.' }; + } + + return { + success: true, + data: result.data.data.map(transformApiToFrontend), + pagination: { + total: result.data.total, + currentPage: result.data.current_page, + lastPage: result.data.last_page, + }, + }; } /** @@ -234,28 +215,22 @@ export async function getSalary(id: string): Promise<{ data?: SalaryDetail; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/salaries/${id}`, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(`${API_URL}/v1/salaries/${id}`, { method: 'GET' }); - const result: SalaryResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '급여 정보를 불러오는데 실패했습니다.' }; - } - - return { - success: true, - data: transformApiToDetail(result.data), - }; - } catch (error) { - console.error('[getSalary] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '급여 정보를 불러오는데 실패했습니다.' }; } + + const result: SalaryResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '급여 정보를 불러오는데 실패했습니다.' }; + } + + return { + success: true, + data: transformApiToDetail(result.data), + }; } /** @@ -265,28 +240,25 @@ export async function updateSalaryStatus( id: string, status: PaymentStatus ): Promise<{ success: boolean; data?: SalaryRecord; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/salaries/${id}/status`, { - method: 'PATCH', - headers, - body: JSON.stringify({ status }), - }); + const { response, error } = await serverFetch(`${API_URL}/v1/salaries/${id}/status`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); - const result: SalaryResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '상태 변경에 실패했습니다.' }; - } - - return { - success: true, - data: transformApiToFrontend(result.data), - }; - } catch (error) { - console.error('[updateSalaryStatus] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '상태 변경에 실패했습니다.' }; } + + const result: SalaryResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '상태 변경에 실패했습니다.' }; + } + + return { + success: true, + data: transformApiToFrontend(result.data), + }; } /** @@ -296,31 +268,28 @@ export async function bulkUpdateSalaryStatus( ids: string[], status: PaymentStatus ): Promise<{ success: boolean; updatedCount?: number; error?: string }> { - try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/salaries/bulk-update-status`, { - method: 'POST', - headers, - body: JSON.stringify({ - ids: ids.map(id => parseInt(id, 10)), - status - }), - }); + const { response, error } = await serverFetch(`${API_URL}/v1/salaries/bulk-update-status`, { + method: 'POST', + body: JSON.stringify({ + ids: ids.map(id => parseInt(id, 10)), + status + }), + }); - const result: BulkUpdateResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '일괄 상태 변경에 실패했습니다.' }; - } - - return { - success: true, - updatedCount: result.data.updated_count, - }; - } catch (error) { - console.error('[bulkUpdateSalaryStatus] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '일괄 상태 변경에 실패했습니다.' }; } + + const result: BulkUpdateResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '일괄 상태 변경에 실패했습니다.' }; + } + + return { + success: true, + updatedCount: result.data.updated_count, + }; } /** @@ -346,44 +315,38 @@ export async function getSalaryStatistics(params?: { }; error?: string }> { - try { - const headers = await getApiHeaders(); - const searchParams = new URLSearchParams(); + const searchParams = new URLSearchParams(); - if (params?.year) searchParams.set('year', String(params.year)); - if (params?.month) searchParams.set('month', String(params.month)); - if (params?.start_date) searchParams.set('start_date', params.start_date); - if (params?.end_date) searchParams.set('end_date', params.end_date); + if (params?.year) searchParams.set('year', String(params.year)); + if (params?.month) searchParams.set('month', String(params.month)); + if (params?.start_date) searchParams.set('start_date', params.start_date); + if (params?.end_date) searchParams.set('end_date', params.end_date); - const url = `${API_URL}/v1/salaries/statistics${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const url = `${API_URL}/v1/salaries/statistics${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + const { response, error } = await serverFetch(url, { method: 'GET' }); - const result: StatisticsResponse = await response.json(); - - if (!response.ok || !result.success) { - return { success: false, error: result.message || '통계 정보를 불러오는데 실패했습니다.' }; - } - - return { - success: true, - data: { - totalNetPayment: result.data.total_net_payment, - totalBaseSalary: result.data.total_base_salary, - totalAllowance: result.data.total_allowance, - totalOvertime: result.data.total_overtime, - totalBonus: result.data.total_bonus, - totalDeduction: result.data.total_deduction, - count: result.data.count, - scheduledCount: result.data.scheduled_count, - completedCount: result.data.completed_count, - }, - }; - } catch (error) { - console.error('[getSalaryStatistics] Error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; + if (error || !response) { + return { success: false, error: error?.message || '통계 정보를 불러오는데 실패했습니다.' }; } + + const result: StatisticsResponse = await response.json(); + + if (!result.success) { + return { success: false, error: result.message || '통계 정보를 불러오는데 실패했습니다.' }; + } + + return { + success: true, + data: { + totalNetPayment: result.data.total_net_payment, + totalBaseSalary: result.data.total_base_salary, + totalAllowance: result.data.total_allowance, + totalOvertime: result.data.total_overtime, + totalBonus: result.data.total_bonus, + totalDeduction: result.data.total_deduction, + count: result.data.count, + scheduledCount: result.data.scheduled_count, + completedCount: result.data.completed_count, + }, + }; } \ No newline at end of file diff --git a/src/components/hr/VacationManagement/actions.ts b/src/components/hr/VacationManagement/actions.ts index 5e1c4edb..0f59ffd9 100644 --- a/src/components/hr/VacationManagement/actions.ts +++ b/src/components/hr/VacationManagement/actions.ts @@ -21,7 +21,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; // ============================================ // 타입 정의 @@ -171,25 +171,6 @@ interface PaginatedResponse { last_page: number; } -// ============================================ -// 헬퍼 함수 -// ============================================ - -/** - * API 헤더 생성 - */ -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // API URL const API_URL = `${process.env.NEXT_PUBLIC_API_URL || process.env.API_URL}/api`; @@ -266,7 +247,6 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params) { @@ -286,10 +266,11 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{ const queryString = searchParams.toString(); const url = `${API_URL}/v1/leaves${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '휴가 목록 조회에 실패했습니다.' }; + } const result: ApiResponse>> = await response.json(); @@ -328,11 +309,11 @@ export async function getLeaveById(id: number): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/${id}`, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}`, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '휴가 조회에 실패했습니다.' }; + } const result: ApiResponse> = await response.json(); @@ -364,10 +345,8 @@ export async function createLeave( data: CreateLeaveRequest ): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves`, { method: 'POST', - headers, body: JSON.stringify({ user_id: data.userId, leave_type: data.leaveType, @@ -378,6 +357,10 @@ export async function createLeave( }), }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 신청에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -409,13 +392,15 @@ export async function approveLeave( comment?: string ): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/${id}/approve`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}/approve`, { method: 'POST', - headers, body: JSON.stringify({ comment }), }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 승인에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -447,13 +432,15 @@ export async function rejectLeave( reason: string ): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/${id}/reject`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}/reject`, { method: 'POST', - headers, body: JSON.stringify({ reason }), }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 반려에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -485,13 +472,15 @@ export async function cancelLeave( reason?: string ): Promise<{ success: boolean; data?: LeaveRecord; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/${id}/cancel`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}/cancel`, { method: 'POST', - headers, body: JSON.stringify({ reason }), }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 취소에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -524,15 +513,15 @@ export async function getMyLeaveBalance(year?: number): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); const url = year ? `${API_URL}/v1/leaves/balance?year=${year}` : `${API_URL}/v1/leaves/balance`; - const response = await fetch(url, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '잔여 휴가 조회에 실패했습니다.' }; + } const result: ApiResponse> = await response.json(); @@ -569,15 +558,15 @@ export async function getUserLeaveBalance( error?: string; }> { try { - const headers = await getApiHeaders(); const url = year ? `${API_URL}/v1/leaves/balance/${userId}?year=${year}` : `${API_URL}/v1/leaves/balance/${userId}`; - const response = await fetch(url, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '잔여 휴가 조회에 실패했습니다.' }; + } const result: ApiResponse> = await response.json(); @@ -609,10 +598,8 @@ export async function setLeaveBalance( data: SetLeaveBalanceRequest ): Promise<{ success: boolean; data?: LeaveBalance; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/balance`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/balance`, { method: 'PUT', - headers, body: JSON.stringify({ user_id: data.userId, year: data.year, @@ -620,6 +607,10 @@ export async function setLeaveBalance( }), }); + if (error || !response) { + return { success: false, error: error?.message || '잔여 휴가 설정에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -648,12 +639,14 @@ export async function setLeaveBalance( */ export async function deleteLeave(id: number): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/${id}`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/${id}`, { method: 'DELETE', - headers, }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 삭제에 실패했습니다.' }; + } + const result: ApiResponse = await response.json(); if (result.success) { @@ -748,7 +741,6 @@ export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params) { @@ -764,10 +756,11 @@ export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise const queryString = searchParams.toString(); const url = `${API_URL}/v1/leaves/balances${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '휴가 사용현황 조회에 실패했습니다.' }; + } const result: ApiResponse>> = await response.json(); @@ -890,7 +883,6 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params) { @@ -910,10 +902,11 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{ const queryString = searchParams.toString(); const url = `${API_URL}/v1/leaves/grants${queryString ? `?${queryString}` : ''}`; - const response = await fetch(url, { - method: 'GET', - headers, - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '휴가 부여 이력 조회에 실패했습니다.' }; + } const result: ApiResponse>> = await response.json(); @@ -950,10 +943,8 @@ export async function createLeaveGrant( data: CreateLeaveGrantRequest ): Promise<{ success: boolean; data?: LeaveGrantRecord; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/grants`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/grants`, { method: 'POST', - headers, body: JSON.stringify({ user_id: data.userId, grant_type: data.grantType, @@ -963,6 +954,10 @@ export async function createLeaveGrant( }), }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 부여에 실패했습니다.' }; + } + const result: ApiResponse> = await response.json(); if (result.success && result.data) { @@ -991,12 +986,14 @@ export async function createLeaveGrant( */ export async function deleteLeaveGrant(id: number): Promise<{ success: boolean; error?: string }> { try { - const headers = await getApiHeaders(); - const response = await fetch(`${API_URL}/v1/leaves/grants/${id}`, { + const { response, error } = await serverFetch(`${API_URL}/v1/leaves/grants/${id}`, { method: 'DELETE', - headers, }); + if (error || !response) { + return { success: false, error: error?.message || '휴가 부여 삭제에 실패했습니다.' }; + } + const result: ApiResponse = await response.json(); if (result.success) { @@ -1069,14 +1066,13 @@ export async function getActiveEmployees(): Promise<{ error?: string; }> { try { - const headers = await getApiHeaders(); const url = `${API_URL}/v1/employees?status=active&per_page=100`; - const response = await fetch(url, { - method: 'GET', - headers, - cache: 'no-store', - }); + const { response, error } = await serverFetch(url, { method: 'GET' }); + + if (error || !response) { + return { success: false, error: error?.message || '직원 목록 조회에 실패했습니다.' }; + } const result = await response.json(); diff --git a/src/components/items/ItemListClient.tsx b/src/components/items/ItemListClient.tsx index 932575d2..fa34e88f 100644 --- a/src/components/items/ItemListClient.tsx +++ b/src/components/items/ItemListClient.tsx @@ -29,6 +29,7 @@ import { import { Search, Plus, Edit, Trash2, Package, Loader2 } from 'lucide-react'; import { TableLoadingSpinner } from '@/components/ui/loading-spinner'; import { useItemList } from '@/hooks/useItemList'; +import { handleApiError } from '@/lib/api/error-handler'; import { IntegratedListTemplateV2, type TabOption, @@ -183,10 +184,15 @@ export default function ItemListClient() { }, }); + // 🚨 401 인증 에러 시 자동 로그인 페이지 리다이렉트 + if (!response.ok) { + await handleApiError(response); + } + const result = await response.json(); console.log('[Delete] 응답:', { status: response.status, result }); - if (response.ok && result.success) { + if (result.success) { refresh(); } else { throw new Error(result.message || '삭제에 실패했습니다.'); @@ -239,6 +245,13 @@ export default function ItemListClient() { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }); + + // 🚨 401 인증 에러 시 자동 로그인 페이지 리다이렉트 + if (response.status === 401) { + await handleApiError(response); + return; // 리다이렉트 후 중단 + } + const result = await response.json(); if (response.ok && result.success) { successCount++; diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts index f6c4c8de..c0ef19a0 100644 --- a/src/components/material/ReceivingManagement/actions.ts +++ b/src/components/material/ReceivingManagement/actions.ts @@ -13,7 +13,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { ReceivingItem, ReceivingDetail, @@ -146,19 +146,6 @@ function transformProcessDataToApi( }; } -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - // ===== 페이지네이션 타입 ===== interface PaginationMeta { currentPage: number; @@ -180,9 +167,9 @@ export async function getReceivings(params?: { data: ReceivingItem[]; pagination: PaginationMeta; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -197,27 +184,33 @@ export async function getReceivings(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings${queryString ? `?${queryString}` : ''}`; - console.log('[ReceivingActions] GET receivings:', url); - - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.warn('[ReceivingActions] GET receivings error:', response.status); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, + error: error.message, + __authError: error.code === 'UNAUTHORIZED', + }; + } + + if (!response) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: '입고 목록 조회에 실패했습니다.', }; } const result = await response.json(); - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, data: [], @@ -262,46 +255,32 @@ export async function getReceivingStats(): Promise<{ success: boolean; data?: ReceivingStats; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/stats`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET', cache: 'no-store' } ); - if (!response.ok) { - console.warn('[ReceivingActions] GET stats error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '입고 통계 조회에 실패했습니다.' }; } const result = await response.json(); - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '입고 통계 조회에 실패했습니다.', - }; + if (!response.ok || !result.success || !result.data) { + return { success: false, error: result.message || '입고 통계 조회에 실패했습니다.' }; } - return { - success: true, - data: transformApiToStats(result.data), - }; + return { success: true, data: transformApiToStats(result.data) }; } catch (error) { console.error('[ReceivingActions] getReceivingStats error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } @@ -310,88 +289,65 @@ export async function getReceivingById(id: string): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET', cache: 'no-store' } ); - if (!response.ok) { - console.error('[ReceivingActions] GET receiving error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '입고 조회에 실패했습니다.' }; } const result = await response.json(); - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '입고 조회에 실패했습니다.', - }; + if (!response.ok || !result.success || !result.data) { + return { success: false, error: result.message || '입고 조회에 실패했습니다.' }; } - return { - success: true, - data: transformApiToDetail(result.data), - }; + return { success: true, data: transformApiToDetail(result.data) }; } catch (error) { console.error('[ReceivingActions] getReceivingById error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ===== 입고 등록 ===== export async function createReceiving( data: Partial -): Promise<{ success: boolean; data?: ReceivingDetail; error?: string }> { +): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); - console.log('[ReceivingActions] POST receiving request:', apiData); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } + { method: 'POST', body: JSON.stringify(apiData) } ); - const result = await response.json(); - console.log('[ReceivingActions] POST receiving response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입고 등록에 실패했습니다.', - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } - return { - success: true, - data: transformApiToDetail(result.data), - }; + if (!response) { + return { success: false, error: '입고 등록에 실패했습니다.' }; + } + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || '입고 등록에 실패했습니다.' }; + } + + return { success: true, data: transformApiToDetail(result.data) }; } catch (error) { console.error('[ReceivingActions] createReceiving error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } @@ -399,77 +355,64 @@ export async function createReceiving( export async function updateReceiving( id: string, data: Partial -): Promise<{ success: boolean; data?: ReceivingDetail; error?: string }> { +): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformFrontendToApi(data); - console.log('[ReceivingActions] PUT receiving request:', apiData); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${id}`, - { - method: 'PUT', - headers, - body: JSON.stringify(apiData), - } + { method: 'PUT', body: JSON.stringify(apiData) } ); - const result = await response.json(); - console.log('[ReceivingActions] PUT receiving response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입고 수정에 실패했습니다.', - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } - return { - success: true, - data: transformApiToDetail(result.data), - }; + if (!response) { + return { success: false, error: '입고 수정에 실패했습니다.' }; + } + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || '입고 수정에 실패했습니다.' }; + } + + return { success: true, data: transformApiToDetail(result.data) }; } catch (error) { console.error('[ReceivingActions] updateReceiving error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ===== 입고 삭제 ===== export async function deleteReceiving( id: string -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${id}`, - { - method: 'DELETE', - headers, - } + { method: 'DELETE' } ); + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '입고 삭제에 실패했습니다.' }; + } + const result = await response.json(); - console.log('[ReceivingActions] DELETE receiving response:', result); if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입고 삭제에 실패했습니다.', - }; + return { success: false, error: result.message || '입고 삭제에 실패했습니다.' }; } return { success: true }; } catch (error) { console.error('[ReceivingActions] deleteReceiving error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } @@ -477,41 +420,32 @@ export async function deleteReceiving( export async function processReceiving( id: string, data: ReceivingProcessFormData -): Promise<{ success: boolean; data?: ReceivingDetail; error?: string }> { +): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean }> { try { - const headers = await getApiHeaders(); const apiData = transformProcessDataToApi(data); - console.log('[ReceivingActions] POST process request:', apiData); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${id}/process`, - { - method: 'POST', - headers, - body: JSON.stringify(apiData), - } + { method: 'POST', body: JSON.stringify(apiData) } ); - const result = await response.json(); - console.log('[ReceivingActions] POST process response:', result); - - if (!response.ok || !result.success) { - return { - success: false, - error: result.message || '입고처리에 실패했습니다.', - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } - return { - success: true, - data: transformApiToDetail(result.data), - }; + if (!response) { + return { success: false, error: '입고처리에 실패했습니다.' }; + } + + const result = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || '입고처리에 실패했습니다.' }; + } + + return { success: true, data: transformApiToDetail(result.data) }; } catch (error) { console.error('[ReceivingActions] processReceiving error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } -} +} \ No newline at end of file diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts index 8fb718e8..cbb282e5 100644 --- a/src/components/material/StockStatus/actions.ts +++ b/src/components/material/StockStatus/actions.ts @@ -10,7 +10,7 @@ 'use server'; -import { cookies } from 'next/headers'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { StockItem, StockDetail, @@ -156,18 +156,6 @@ function transformApiToStats(data: StockApiStatsResponse): StockStats { }; } -// ===== API 헤더 생성 ===== -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} // ===== 페이지네이션 타입 ===== interface PaginationMeta { @@ -192,9 +180,9 @@ export async function getStocks(params?: { data: StockItem[]; pagination: PaginationMeta; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); @@ -213,27 +201,33 @@ export async function getStocks(params?: { const queryString = searchParams.toString(); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks${queryString ? `?${queryString}` : ''}`; - console.log('[StockActions] GET stocks:', url); - - const response = await fetch(url, { + const { response, error } = await serverFetch(url, { method: 'GET', - headers, cache: 'no-store', }); - if (!response.ok) { - console.warn('[StockActions] GET stocks error:', response.status); + if (error) { return { success: false, data: [], pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: `API 오류: ${response.status}`, + error: error.message, + __authError: error.code === 'UNAUTHORIZED', + }; + } + + if (!response) { + return { + success: false, + data: [], + pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, + error: '재고 목록 조회에 실패했습니다.', }; } const result = await response.json(); - if (!result.success) { + if (!response.ok || !result.success) { return { success: false, data: [], @@ -278,46 +272,32 @@ export async function getStockStats(): Promise<{ success: boolean; data?: StockStats; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/stats`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET', cache: 'no-store' } ); - if (!response.ok) { - console.warn('[StockActions] GET stats error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '재고 통계 조회에 실패했습니다.' }; } const result = await response.json(); - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '재고 통계 조회에 실패했습니다.', - }; + if (!response.ok || !result.success || !result.data) { + return { success: false, error: result.message || '재고 통계 조회에 실패했습니다.' }; } - return { - success: true, - data: transformApiToStats(result.data), - }; + return { success: true, data: transformApiToStats(result.data) }; } catch (error) { console.error('[StockActions] getStockStats error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } @@ -326,46 +306,32 @@ export async function getStockStatsByType(): Promise<{ success: boolean; data?: StockApiStatsByTypeResponse; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/stats-by-type`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET', cache: 'no-store' } ); - if (!response.ok) { - console.warn('[StockActions] GET stats-by-type error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '품목유형별 통계 조회에 실패했습니다.' }; } const result = await response.json(); - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '품목유형별 통계 조회에 실패했습니다.', - }; + if (!response.ok || !result.success || !result.data) { + return { success: false, error: result.message || '품목유형별 통계 조회에 실패했습니다.' }; } - return { - success: true, - data: result.data, - }; + return { success: true, data: result.data }; } catch (error) { console.error('[StockActions] getStockStatsByType error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } @@ -374,45 +340,31 @@ export async function getStockById(id: string): Promise<{ success: boolean; data?: StockDetail; error?: string; + __authError?: boolean; }> { try { - const headers = await getApiHeaders(); - - const response = await fetch( + const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/stocks/${id}`, - { - method: 'GET', - headers, - cache: 'no-store', - } + { method: 'GET', cache: 'no-store' } ); - if (!response.ok) { - console.error('[StockActions] GET stock error:', response.status); - return { - success: false, - error: `API 오류: ${response.status}`, - }; + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '재고 조회에 실패했습니다.' }; } const result = await response.json(); - if (!result.success || !result.data) { - return { - success: false, - error: result.message || '재고 조회에 실패했습니다.', - }; + if (!response.ok || !result.success || !result.data) { + return { success: false, error: result.message || '재고 조회에 실패했습니다.' }; } - return { - success: true, - data: transformApiToDetail(result.data), - }; + return { success: true, data: transformApiToDetail(result.data) }; } catch (error) { console.error('[StockActions] getStockById error:', error); - return { - success: false, - error: '서버 오류가 발생했습니다.', - }; + return { success: false, error: '서버 오류가 발생했습니다.' }; } } diff --git a/src/components/orders/documents/OrderDocumentModal.tsx b/src/components/orders/documents/OrderDocumentModal.tsx index 8a552dab..270d9fb4 100644 --- a/src/components/orders/documents/OrderDocumentModal.tsx +++ b/src/components/orders/documents/OrderDocumentModal.tsx @@ -163,7 +163,8 @@ export function OrderDocumentModal({ {/* 버튼 영역 - 고정 */}
- @@ -178,7 +179,7 @@ export function OrderDocumentModal({ + */}