feat: fetchWrapper 마이그레이션 및 토큰 리프레시 캐싱 구현
- 40+ actions.ts 파일을 fetchWrapper 패턴으로 마이그레이션 - 토큰 리프레시 캐싱 로직 추가 (refresh-token.ts) - ApiErrorContext 추가로 전역 에러 처리 개선 - HR EmployeeForm 컴포넌트 개선 - 참조함(ReferenceBox) 기능 수정 - juil 테스트 URL 페이지 추가 - claudedocs 문서 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
42
claudedocs/[REF] juil-pages-test-urls.md
Normal file
42
claudedocs/[REF] juil-pages-test-urls.md
Normal file
@@ -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` | ✅ 완료 |
|
||||
82
claudedocs/[REF] juil-project-flow.md
Normal file
82
claudedocs/[REF] juil-project-flow.md
Normal file
@@ -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) <br> Bid Participation <br> Win/Loss Check | Estimate Created <br> Bid Submitted <br> Project Won/Lost |
|
||||
| **Contract Manager** | Create Contract (Approve/Return) <br> Contract Execution <br> Handover Decision | Contract Finalized |
|
||||
| **Order/Construction Manager** | Handover Creation (Approve/Return) <br> Field Measurement <br> Structural Review (if needed) <br> Order Creation (Approve/Return) <br> Construction Start | Handover Doc <br> Measurement Data <br> Structural Report <br> Order Placed |
|
||||
| **Progress Billing Manager** | Create Progress Billing (Approve/Return) <br> Change Contract Check <br> Client Approval <br> Settlement | Bill Created <br> 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 (정산관리)
|
||||
@@ -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/ - 레거시/완료된 문서
|
||||
|
||||
완료되거나 더 이상 활성화되지 않은 문서들. 참조용으로 보관.
|
||||
|
||||
262
claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md
Normal file
262
claudedocs/api/[IMPL-2025-12-30] fetch-wrapper-migration.md
Normal file
@@ -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<HeadersInit> {
|
||||
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');
|
||||
}
|
||||
```
|
||||
@@ -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개
|
||||
329
claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md
Normal file
329
claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md
Normal file
@@ -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<RefreshResult> | null;
|
||||
timestamp: number;
|
||||
result: RefreshResult | null;
|
||||
} = {
|
||||
promise: null,
|
||||
timestamp: 0,
|
||||
result: null,
|
||||
};
|
||||
|
||||
const REFRESH_CACHE_TTL = 5000; // 5초
|
||||
|
||||
/**
|
||||
* 실제 토큰 갱신 수행 (내부 함수)
|
||||
*/
|
||||
async function doRefreshToken(refreshToken: string): Promise<RefreshResult> {
|
||||
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<RefreshResult> {
|
||||
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은 충분히 짧아 토큰 갱신 지연 문제 없음
|
||||
- 실패 시 다음 요청에서 새로 갱신 시도
|
||||
- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화
|
||||
89
claudedocs/juil/[REF] juil-project-structure.md
Normal file
89
claudedocs/juil/[REF] juil-project-structure.md
Normal file
@@ -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/` - 컴포넌트 파일
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={handleOpen}
|
||||
className="group flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
{item.status && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700">
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{item.url}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="URL 복사"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl: string }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [subExpanded, setSubExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
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 (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full text-left mb-3"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||
)}
|
||||
<span className="text-xl">{category.icon}</span>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{category.title}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 ml-auto">
|
||||
{totalItems} 링크
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="pl-7 space-y-4">
|
||||
{category.items.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
{category.items.map((item) => (
|
||||
<UrlCard key={item.url} item={item} baseUrl={baseUrl} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category.subCategories?.map((sub) => (
|
||||
<div key={sub.title} className="mt-3">
|
||||
<button
|
||||
onClick={() => toggleSub(sub.title)}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{subExpanded[sub.title] !== false ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
{sub.title}
|
||||
<span className="text-xs text-gray-400">({sub.items.length})</span>
|
||||
</button>
|
||||
{subExpanded[sub.title] !== false && (
|
||||
<div className="grid gap-2 pl-6">
|
||||
{sub.items.map((item) => (
|
||||
<UrlCard key={item.url} item={item} baseUrl={baseUrl} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
🏭 주일기업 테스트 URL 목록
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="새로고침 (md 파일 변경사항 반영)"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
주일기업용 백엔드 메뉴 연동 전 테스트용 직접 접근 URL ({totalLinks}개)
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
클릭하면 새 탭에서 열립니다 • 최종 업데이트: {lastUpdated}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
✨ md 파일 수정 시 자동 반영됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search & Base URL */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="페이지 또는 URL 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-sm text-gray-500">Base:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
className="w-48 text-sm bg-transparent text-gray-900 dark:text-white focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="space-y-2">
|
||||
{filteredData.map((category) => (
|
||||
<CategorySection key={category.title} category={category} baseUrl={baseUrl} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredData.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
📁 데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] juil-pages-test-urls.md</code>
|
||||
</p>
|
||||
<p className="mt-1 text-green-600 dark:text-green-400">
|
||||
md 파일 수정 후 새로고침하면 자동 반영!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/app/[locale]/(protected)/dev/juil-test-urls/page.tsx
Normal file
152
src/app/[locale]/(protected)/dev/juil-test-urls/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import JuilTestUrlsClient, { UrlCategory, UrlItem } from './JuilTestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'시스템': '💻',
|
||||
'대시보드': '📊',
|
||||
};
|
||||
|
||||
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 <JuilTestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
|
||||
// 캐싱 비활성화 - 항상 최신 md 파일 읽기
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
@@ -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 (
|
||||
<>
|
||||
<EmployeeDetail
|
||||
<EmployeeForm
|
||||
mode="view"
|
||||
employee={employee}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartnerListClient } from '@/components/business/juil/partners';
|
||||
|
||||
export default function PartnersPage() {
|
||||
return <PartnerListClient />;
|
||||
}
|
||||
@@ -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 (
|
||||
<RootProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
<ApiErrorProvider>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
</ApiErrorProvider>
|
||||
</RootProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
65
src/app/api/files/[id]/download/route.ts
Normal file
65
src/app/api/files/[id]/download/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<BadDebtRecord[]> {
|
||||
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<BadDebtRecord | null> {
|
||||
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<BadDebtRecord | null>
|
||||
*/
|
||||
export async function getBadDebtSummary(): Promise<BadDebtSummaryApiData | null> {
|
||||
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<BadDebtRecord>
|
||||
): 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<BadDebtRecord>
|
||||
): 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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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<BillRecord>
|
||||
): 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<BillRecord>
|
||||
): 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) {
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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<DepositRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
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<DepositRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<DepositRecord>
|
||||
): 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<DepositRecord>
|
||||
): 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 };
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<ExpectedExpenseRecord>
|
||||
): 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<ExpectedExpenseRecord>
|
||||
): 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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PurchaseRecord>): Record<string, u
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<PurchaseRecord>
|
||||
): 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<PurchaseRecord>
|
||||
): 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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<SalesRecord>
|
||||
): 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<SalesRecord>
|
||||
): 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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<PaginatedResponse<ClientApiData>> = 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<PaginatedResponse<ClientApiData>> = 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<Vendor | null> {
|
||||
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<ClientApiData> = 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<ClientApiData> = 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<Vendor | null> {
|
||||
export async function createClient(
|
||||
data: Partial<Vendor>
|
||||
): 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<Vendor>
|
||||
): 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) };
|
||||
}
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<WithdrawalRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
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<WithdrawalRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<WithdrawalRecord>
|
||||
): 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<WithdrawalRecord>
|
||||
): 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 };
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<string, string> = {
|
||||
'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<PaginatedResponse<InboxApiData>> = 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<InboxSummary | null> {
|
||||
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<InboxSummary> = 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<InboxSummary | null> {
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -68,24 +68,23 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={person.id} className="flex items-center gap-2">
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? '' : person.id}
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
{/* 이미 선택된 값이 있으면 직접 표시, 없으면 placeholder */}
|
||||
{person.name && !person.id.startsWith('temp-') ? (
|
||||
<span>{person.department} / {person.position} / {person.name}</span>
|
||||
) : (
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
|
||||
)}
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department} / {employee.position} / {employee.name}
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -68,24 +68,23 @@ export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={person.id} className="flex items-center gap-2">
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? '' : person.id}
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
{/* 이미 선택된 값이 있으면 직접 표시, 없으면 placeholder */}
|
||||
{person.name && !person.id.startsWith('temp-') ? (
|
||||
<span>{person.department} / {person.position} / {person.name}</span>
|
||||
) : (
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
|
||||
)}
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department} / {employee.position} / {employee.name}
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<ApprovalPerson[]> {
|
||||
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<ApprovalPerson[]> {
|
||||
|
||||
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<ApprovalCreateResponse> = 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) {
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<PaginatedResponse<ApprovalApiData>> = 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<DraftsSummary | null> {
|
||||
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<DraftsSummary> = 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<DraftsSummary | null> {
|
||||
*/
|
||||
export async function getDraftById(id: string): Promise<DraftRecord | null> {
|
||||
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<ApprovalApiData> = 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<DraftRecord | null> {
|
||||
/**
|
||||
* 결재 문서 삭제 (임시저장 상태만)
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<ReferenceRecord | null>(null);
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ReferenceRecord[]>([]);
|
||||
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 (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center">
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
@@ -297,18 +387,13 @@ export function ReferenceBox() {
|
||||
<TableCell>{item.drafter}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.documentStatus]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.documentStatus]}
|
||||
<Badge className={READ_STATUS_COLORS[item.readStatus]}>
|
||||
{READ_STATUS_LABELS[item.readStatus]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.readStatus === 'read' && (
|
||||
<Check className="h-4 w-4 mx-auto text-green-600" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection]);
|
||||
}, [selectedItems, toggleSelection, handleDocumentClick]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
@@ -325,14 +410,9 @@ export function ReferenceBox() {
|
||||
headerBadges={
|
||||
<div className="flex gap-1">
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.documentStatus]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.documentStatus]}
|
||||
<Badge className={READ_STATUS_COLORS[item.readStatus]}>
|
||||
{READ_STATUS_LABELS[item.readStatus]}
|
||||
</Badge>
|
||||
{item.readStatus === 'read' ? (
|
||||
<Badge className="bg-gray-100 text-gray-800">열람</Badge>
|
||||
) : (
|
||||
<Badge className="bg-blue-100 text-blue-800">미열람</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
@@ -505,6 +585,17 @@ export function ReferenceBox() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
mode="reference"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -75,21 +75,6 @@ interface PaginatedResponse<T> {
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<string, unknown>): 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<PaginatedResponse<Record<string, unknown>>> = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
|
||||
@@ -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<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<BoardApiData[]> = await response.json();
|
||||
let result: ApiResponse<BoardApiData[]>;
|
||||
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<BoardApiData> = await response.json();
|
||||
let result: ApiResponse<BoardApiData>;
|
||||
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<BoardApiData> = await response.json();
|
||||
let result: ApiResponse<BoardApiData>;
|
||||
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<BoardApiData>;
|
||||
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<BoardApiData>;
|
||||
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 {
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<PostApiData> = 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<string, 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`;
|
||||
|
||||
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<string, 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: '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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
src/components/business/juil/JuilDashboard.tsx
Normal file
19
src/components/business/juil/JuilDashboard.tsx
Normal file
@@ -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 (
|
||||
<Suspense fallback={<PageLoadingSpinner text="공사 현황을 불러오는 중..." />}>
|
||||
<JuilMainDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
195
src/components/business/juil/JuilMainDashboard.tsx
Normal file
195
src/components/business/juil/JuilMainDashboard.tsx
Normal file
@@ -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 (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 섹션 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-4 md:p-6 mb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Building2 className="w-8 h-8 text-blue-600" />
|
||||
주일기업 프로젝트 현황
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{currentTime} · 실시간 현장 모니터링
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
일일 작업보고
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상단 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">진행중인 현장</CardTitle>
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{projectStats.active}개소</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
총 {projectStats.totalProjects}개 프로젝트 중
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">금일 출력 인원</CardTitle>
|
||||
<HardHat className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">142명</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<span className="text-green-500 font-medium">+12명</span> (전일 대비)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">입찰 진행</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{projectStats.bidding}건</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
마감 임박 1건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">안전 이슈</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">0건</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
무재해 125일째
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 주요 프로젝트 진행 현황 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Hammer className="w-5 h-5" />
|
||||
주요 프로젝트 진행 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentProjects.map((project) => (
|
||||
<div key={project.id} className="flex items-center justify-between p-4 bg-muted/40 rounded-lg border">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-bold">{project.name}</span>
|
||||
<Badge variant={project.status === '공사중' ? 'default' : 'secondary'}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardHat className="w-3 h-3" /> {project.manager}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="w-3 h-3" /> {project.deadline}까지
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32 text-right">
|
||||
<div className="text-2xl font-bold text-blue-600">{project.progress}%</div>
|
||||
<div className="text-xs text-muted-foreground">공정률</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 일정 / 알림 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
주요 일정 (현설/입찰)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="flex gap-3 items-start border-l-4 border-blue-200 pl-3 py-1">
|
||||
<div className="flex-1">
|
||||
<Badge variant="outline" className="mb-1 text-xs">
|
||||
{event.type}
|
||||
</Badge>
|
||||
<p className="font-medium text-sm">{event.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>{event.date}</span>
|
||||
<span>|</span>
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" className="w-full text-sm text-muted-foreground">
|
||||
전체 일정 보기
|
||||
<ArrowUpRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
493
src/components/business/juil/partners/PartnerListClient.tsx
Normal file
493
src/components/business/juil/partners/PartnerListClient.tsx
Normal file
@@ -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<Partner[]>(initialData);
|
||||
const [stats, setStats] = useState<PartnerStats>(
|
||||
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<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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 (
|
||||
<TableRow
|
||||
key={partner.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(partner)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(partner.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{partner.partnerCode}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary">{partner.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{partner.partnerName}</TableCell>
|
||||
<TableCell>{partner.representative}</TableCell>
|
||||
<TableCell>{partner.manager}</TableCell>
|
||||
<TableCell>{partner.phone}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{partner.paymentDay ? `${partner.paymentDay}일` : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{partner.isBadDebt ? (
|
||||
<Badge variant="destructive">악성채권</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, partner.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, partner.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(partner: Partner, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={partner.partnerName}
|
||||
subtitle={partner.partnerCode}
|
||||
badge={partner.isBadDebt ? '악성채권' : undefined}
|
||||
badgeVariant={partner.isBadDebt ? 'destructive' : 'secondary'}
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => 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 = (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 악성채권 필터 */}
|
||||
<Select value={badDebtFilter} onValueChange={(v) => setBadDebtFilter(v as typeof badDebtFilter)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="badDebt">악성채권</SelectItem>
|
||||
<SelectItem value="normal">정상</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">최신순</SelectItem>
|
||||
<SelectItem value="oldest">등록순</SelectItem>
|
||||
<SelectItem value="nameAsc">거래처명 오름차순</SelectItem>
|
||||
<SelectItem value="nameDesc">거래처명 내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 등록 버튼 */}
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
거래처 등록
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="거래처 관리"
|
||||
description="거래처 정보를 관리합니다"
|
||||
icon={Building2}
|
||||
headerActions={headerActions}
|
||||
stats={[
|
||||
{
|
||||
label: '전체 거래처',
|
||||
value: stats.total,
|
||||
icon: Building2,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '미등록',
|
||||
value: stats.unregistered,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
]}
|
||||
tabs={tabOptions}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedPartners}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedPartners.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 거래처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
223
src/components/business/juil/partners/actions.ts
Normal file
223
src/components/business/juil/partners/actions.ts
Normal file
@@ -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: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
3
src/components/business/juil/partners/index.ts
Normal file
3
src/components/business/juil/partners/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as PartnerListClient } from './PartnerListClient';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
57
src/components/business/juil/partners/types.ts
Normal file
57
src/components/business/juil/partners/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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<PostApiData> = 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<string, 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`;
|
||||
|
||||
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<string, 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: '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<CommentsApiResponse> = 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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<EmployeeOption[]> {
|
||||
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<PaginatedResponse<EmployeeApiData>> = 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<PaginatedResponse<EmployeeApiData>> = 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<PaginatedResponse<AttendanceApiData>> = 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<PaginatedResponse<AttendanceApiData>> = 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<AttendanceRecord | null> {
|
||||
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<AttendanceApiData> = 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<AttendanceApiData> = 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<AttendanceRecord |
|
||||
export async function createAttendance(
|
||||
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] 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<AttendanceStats | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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 };
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -91,20 +91,8 @@ interface ApiResponse<T> {
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<ApiDepartment[]> = 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<ApiDepartment[]> = 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<ApiDepartment> = 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<ApiDepartment> = 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<ApiDepartment> = 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<ApiDepartment> = 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<ApiDepartment> = 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<ApiDepartment> = 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 || '부서 삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<EmployeeFormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
@@ -97,7 +115,12 @@ export function EmployeeForm({
|
||||
const [showFieldSettings, setShowFieldSettings] = useState(false);
|
||||
const [fieldSettings, setFieldSettings] = useState<FieldSettings>(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({
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={mode === 'create' ? '새로운 사원 정보를 입력합니다' : '사원 정보를 수정합니다'}
|
||||
description={description}
|
||||
icon={Users}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFieldSettings(true)}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
항목 설정
|
||||
</Button>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFieldSettings(true)}
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
항목 설정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
@@ -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 && <p className="text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -245,6 +337,7 @@ export function EmployeeForm({
|
||||
value={formData.residentNumber}
|
||||
onChange={(e) => handleChange('residentNumber', e.target.value)}
|
||||
placeholder="000000-0000000"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,18 +348,22 @@ export function EmployeeForm({
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Label htmlFor="email">이메일 *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="email@company.com"
|
||||
disabled={isViewMode}
|
||||
className={errors.email ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -277,6 +374,7 @@ export function EmployeeForm({
|
||||
value={formData.salary}
|
||||
onChange={(e) => handleChange('salary', e.target.value)}
|
||||
placeholder="연봉 (원)"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,16 +387,19 @@ export function EmployeeForm({
|
||||
value={formData.bankAccount.bankName}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })}
|
||||
placeholder="은행명"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Input
|
||||
value={formData.bankAccount.accountNumber}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })}
|
||||
placeholder="계좌번호"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Input
|
||||
value={formData.bankAccount.accountHolder}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })}
|
||||
placeholder="예금주"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,32 +419,46 @@ export function EmployeeForm({
|
||||
{fieldSettings.showProfileImage && (
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<Label>프로필 사진</Label>
|
||||
<div className="w-32 h-32 border border-dashed border-gray-300 rounded-md flex flex-col items-center justify-center bg-gray-50 relative cursor-pointer hover:bg-gray-100">
|
||||
<span className="text-sm text-gray-400 mb-1">IMG</span>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onChange={async (e) => {
|
||||
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);
|
||||
<div className={`w-32 h-32 border border-dashed border-gray-300 rounded-md flex flex-col items-center justify-center bg-gray-50 relative ${isViewMode ? '' : 'cursor-pointer hover:bg-gray-100'}`}>
|
||||
{formData.profileImage ? (
|
||||
<img
|
||||
src={formData.profileImage}
|
||||
alt="프로필"
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 mb-1">IMG</span>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isViewMode && (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onChange={async (e) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
1 250 X 250px, 10MB 이하의<br />PNG, JPEG, GIF
|
||||
</p>
|
||||
{!isViewMode && (
|
||||
<p className="text-xs text-gray-500">
|
||||
1 250 X 250px, 10MB 이하의<br />PNG, JPEG, GIF
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -358,6 +473,7 @@ export function EmployeeForm({
|
||||
value={formData.employeeCode}
|
||||
onChange={(e) => handleChange('employeeCode', e.target.value)}
|
||||
placeholder="사원코드를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="male" id="gender-male" />
|
||||
<RadioGroupItem value="male" id="gender-male" disabled={isViewMode} />
|
||||
<Label htmlFor="gender-male" className="font-normal cursor-pointer">남성</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="female" id="gender-female" />
|
||||
<RadioGroupItem value="female" id="gender-female" disabled={isViewMode} />
|
||||
<Label htmlFor="gender-female" className="font-normal cursor-pointer">여성</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -388,21 +505,25 @@ export function EmployeeForm({
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="default" size="sm" onClick={openPostcode} className="bg-blue-500 hover:bg-blue-600">
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
{!isViewMode && (
|
||||
<Button type="button" variant="default" size="sm" onClick={openPostcode} className="bg-blue-500 hover:bg-blue-600">
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
value={formData.address.zipCode}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, zipCode: e.target.value })}
|
||||
placeholder=""
|
||||
className="w-24"
|
||||
readOnly
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Input
|
||||
value={formData.address.address2}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, address2: e.target.value })}
|
||||
placeholder="상세주소를 입력해주세요"
|
||||
className="flex-1"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,6 +550,7 @@ export function EmployeeForm({
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleChange('hireDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -439,8 +561,9 @@ export function EmployeeForm({
|
||||
<Select
|
||||
value={formData.employmentType}
|
||||
onValueChange={(value) => handleChange('employmentType', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="고용형태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -460,6 +583,7 @@ export function EmployeeForm({
|
||||
value={formData.rank}
|
||||
onChange={(e) => handleChange('rank', e.target.value)}
|
||||
placeholder="직급 입력"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -470,8 +594,9 @@ export function EmployeeForm({
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleChange('status', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -489,20 +614,22 @@ export function EmployeeForm({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>부서/직책</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDepartmentPosition}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDepartmentPosition}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.departmentPositions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center border rounded-md">
|
||||
부서/직책을 추가해주세요
|
||||
{isViewMode ? '등록된 부서/직책이 없습니다' : '부서/직책을 추가해주세요'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -513,21 +640,25 @@ export function EmployeeForm({
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)}
|
||||
placeholder="부서명"
|
||||
className="flex-1"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Input
|
||||
value={dp.positionName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)}
|
||||
placeholder="직책"
|
||||
className="flex-1"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDepartmentPosition(dp.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDepartmentPosition(dp.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -543,8 +674,9 @@ export function EmployeeForm({
|
||||
<Select
|
||||
value={formData.clockInLocation}
|
||||
onValueChange={(value) => handleChange('clockInLocation', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="출근 위치 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -563,8 +695,9 @@ export function EmployeeForm({
|
||||
<Select
|
||||
value={formData.clockOutLocation}
|
||||
onValueChange={(value) => handleChange('clockOutLocation', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="퇴근 위치 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -589,6 +722,7 @@ export function EmployeeForm({
|
||||
type="date"
|
||||
value={formData.resignationDate}
|
||||
onChange={(e) => handleChange('resignationDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -601,6 +735,7 @@ export function EmployeeForm({
|
||||
value={formData.resignationReason}
|
||||
onChange={(e) => handleChange('resignationReason', e.target.value)}
|
||||
placeholder="퇴직 사유를 입력하세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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 && <p className="text-sm text-red-500">{errors.userId}</p>}
|
||||
</div>
|
||||
|
||||
{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 && <p className="text-sm text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">비밀번호 확인</Label>
|
||||
<Label htmlFor="confirmPassword">비밀번호 확인 *</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
placeholder="비밀번호 확인"
|
||||
className={errors.confirmPassword ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.confirmPassword && <p className="text-sm text-red-500">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -659,8 +801,9 @@ export function EmployeeForm({
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value) => handleChange('role', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="권한 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -676,8 +819,9 @@ export function EmployeeForm({
|
||||
<Select
|
||||
value={formData.accountStatus}
|
||||
onValueChange={(value) => handleChange('accountStatus', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -695,12 +839,25 @@ export function EmployeeForm({
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
목록
|
||||
</Button>
|
||||
{isViewMode ? (
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={onDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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<T> {
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<Employee | null> {
|
||||
export async function getEmployeeById(id: string): Promise<Employee | null | { __authError: true }> {
|
||||
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<Employee | null> {
|
||||
*/
|
||||
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<EmployeeStats | null> {
|
||||
export async function getEmployeeStats(): Promise<EmployeeStats | null | { __authError: true }> {
|
||||
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}` };
|
||||
}
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -171,25 +171,6 @@ interface PaginatedResponse<T> {
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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<PaginatedResponse<Record<string, unknown>>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<Record<string, unknown>> = 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<null> = 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<PaginatedResponse<Record<string, unknown>>> = 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<PaginatedResponse<Record<string, unknown>>> = 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<Record<string, unknown>> = 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<null> = 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();
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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<HeadersInit> {
|
||||
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<ReceivingDetail>
|
||||
): 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<ReceivingDetail>
|
||||
): 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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HeadersInit> {
|
||||
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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,8 @@ export function OrderDocumentModal({
|
||||
|
||||
{/* 버튼 영역 - 고정 */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleSharePdf}>
|
||||
{/* TODO: 공유 기능 추가 예정 - PDF 다운로드, 이메일, 팩스, 카카오톡 공유 */}
|
||||
{/* <Button variant="outline" size="sm" onClick={handleSharePdf}>
|
||||
<FileDown className="h-4 w-4 mr-1" />
|
||||
PDF
|
||||
</Button>
|
||||
@@ -178,7 +179,7 @@ export function OrderDocumentModal({
|
||||
<Button variant="outline" size="sm" onClick={handleShareKakao}>
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
공유
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
ShipmentItem,
|
||||
ShipmentDetail,
|
||||
@@ -233,18 +233,6 @@ function transformEditFormToApi(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 {
|
||||
@@ -273,9 +261,9 @@ export async function getShipments(params?: {
|
||||
data: ShipmentItem[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -304,19 +292,28 @@ export async function getShipments(params?: {
|
||||
|
||||
console.log('[ShipmentActions] GET shipments:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ShipmentActions] GET shipments 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 || !response.ok) {
|
||||
console.warn('[ShipmentActions] GET shipments error:', response?.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,46 +364,36 @@ export async function getShipmentStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ShipmentStats;
|
||||
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/shipments/stats`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ShipmentActions] 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 || !response.ok) {
|
||||
console.warn('[ShipmentActions] GET stats 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: false, error: result.message || '출하 통계 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformApiToStats(result.data),
|
||||
};
|
||||
return { success: true, data: transformApiToStats(result.data) };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getShipmentStats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,46 +402,36 @@ export async function getShipmentStatsByStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: ShipmentApiStatsByStatusResponse;
|
||||
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/shipments/stats-by-status`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ShipmentActions] GET stats-by-status error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[ShipmentActions] GET stats-by-status 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: false, error: result.message || '상태별 통계 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getShipmentStatsByStatus error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,88 +440,74 @@ export async function getShipmentById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ShipmentDetail;
|
||||
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/shipments/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[ShipmentActions] GET shipment error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.error('[ShipmentActions] GET shipment 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: false, error: result.message || '출하 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformApiToDetail(result.data),
|
||||
};
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getShipmentById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 등록 =====
|
||||
export async function createShipment(
|
||||
data: ShipmentCreateFormData
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformCreateFormToApi(data);
|
||||
|
||||
console.log('[ShipmentActions] POST shipment request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[ShipmentActions] POST shipment response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '출하 등록에 실패했습니다.',
|
||||
};
|
||||
return { success: false, error: result.message || '출하 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformApiToDetail(result.data),
|
||||
};
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] createShipment error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,42 +515,38 @@ export async function createShipment(
|
||||
export async function updateShipment(
|
||||
id: string,
|
||||
data: Partial<ShipmentEditFormData>
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformEditFormToApi(data);
|
||||
|
||||
console.log('[ShipmentActions] PUT shipment request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[ShipmentActions] PUT shipment response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '출하 수정에 실패했습니다.',
|
||||
};
|
||||
return { success: false, error: result.message || '출하 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformApiToDetail(result.data),
|
||||
};
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] updateShipment error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,10 +562,8 @@ export async function updateShipmentStatus(
|
||||
driverContact?: string;
|
||||
confirmedArrival?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const apiData: Record<string, unknown> = { status };
|
||||
if (additionalData?.loadingTime) apiData.loading_time = additionalData.loadingTime;
|
||||
if (additionalData?.loadingCompletedAt) apiData.loading_completed_at = additionalData.loadingCompletedAt;
|
||||
@@ -617,70 +574,67 @@ export async function updateShipmentStatus(
|
||||
|
||||
console.log('[ShipmentActions] PATCH status request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}/status`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[ShipmentActions] PATCH status response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '상태 변경에 실패했습니다.',
|
||||
};
|
||||
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformApiToDetail(result.data),
|
||||
};
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] updateShipmentStatus error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 삭제 =====
|
||||
export async function deleteShipment(
|
||||
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/shipments/${id}`,
|
||||
{
|
||||
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();
|
||||
console.log('[ShipmentActions] DELETE shipment 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('[ShipmentActions] deleteShipment error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,49 +643,36 @@ export async function getLotOptions(): Promise<{
|
||||
success: boolean;
|
||||
data: LotOption[];
|
||||
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/shipments/options/lots`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ShipmentActions] GET lot options error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
if (error) {
|
||||
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[ShipmentActions] GET lot options error:', response?.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response?.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: result.message || 'LOT 옵션 조회에 실패했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: result.message || 'LOT 옵션 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || [],
|
||||
};
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getLotOptions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,49 +681,36 @@ export async function getLogisticsOptions(): Promise<{
|
||||
success: boolean;
|
||||
data: LogisticsOption[];
|
||||
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/shipments/options/logistics`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ShipmentActions] GET logistics options error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
if (error) {
|
||||
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[ShipmentActions] GET logistics options error:', response?.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response?.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: result.message || '물류사 옵션 조회에 실패했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: result.message || '물류사 옵션 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || [],
|
||||
};
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getLogisticsOptions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,48 +719,35 @@ export async function getVehicleTonnageOptions(): Promise<{
|
||||
success: boolean;
|
||||
data: VehicleTonnageOption[];
|
||||
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/shipments/options/vehicle-tonnage`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ShipmentActions] GET vehicle tonnage options error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
if (error) {
|
||||
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[ShipmentActions] GET vehicle tonnage options error:', response?.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response?.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: result.message || '차량 톤수 옵션 조회에 실패했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: result.message || '차량 톤수 옵션 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data || [],
|
||||
};
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getVehicleTonnageOptions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { PricingData, ItemInfo } from './types';
|
||||
|
||||
// API 응답 타입
|
||||
@@ -83,21 +83,6 @@ interface PriceApiData {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 데이터 → 프론트엔드 타입 변환
|
||||
*/
|
||||
@@ -178,17 +163,24 @@ function transformFrontendToApi(data: PricingData): Record<string, unknown> {
|
||||
*/
|
||||
export async function getPricingById(id: string): Promise<PricingData | null> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('[PricingActions] GET pricing error:', error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
console.error('[PricingActions] GET pricing: 응답이 없습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PricingActions] GET pricing error:', response.status);
|
||||
return null;
|
||||
@@ -215,17 +207,24 @@ export async function getPricingById(id: string): Promise<PricingData | null> {
|
||||
*/
|
||||
export async function getItemInfo(itemId: string): Promise<ItemInfo | null> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items/${itemId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('[PricingActions] getItemInfo error:', error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
console.error('[PricingActions] getItemInfo: 응답이 없습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PricingActions] Item not found:', itemId);
|
||||
return null;
|
||||
@@ -257,22 +256,35 @@ export async function getItemInfo(itemId: string): Promise<ItemInfo | null> {
|
||||
*/
|
||||
export async function createPricing(
|
||||
data: PricingData
|
||||
): Promise<{ success: boolean; data?: PricingData; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[PricingActions] POST pricing request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[PricingActions] POST pricing response:', result);
|
||||
|
||||
@@ -303,9 +315,8 @@ export async function updatePricing(
|
||||
id: string,
|
||||
data: PricingData,
|
||||
changeReason?: string
|
||||
): Promise<{ success: boolean; data?: PricingData; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = {
|
||||
...transformFrontendToApi(data),
|
||||
change_reason: changeReason || null,
|
||||
@@ -313,15 +324,29 @@ export async function updatePricing(
|
||||
|
||||
console.log('[PricingActions] PUT pricing request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[PricingActions] PUT pricing response:', result);
|
||||
|
||||
@@ -348,18 +373,30 @@ export async function updatePricing(
|
||||
/**
|
||||
* 단가 삭제
|
||||
*/
|
||||
export async function deletePricing(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function deletePricing(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/pricing/${id}`,
|
||||
{
|
||||
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();
|
||||
console.log('[PricingActions] DELETE pricing response:', result);
|
||||
|
||||
@@ -383,18 +420,30 @@ export async function deletePricing(id: string): Promise<{ success: boolean; err
|
||||
/**
|
||||
* 단가 확정
|
||||
*/
|
||||
export async function finalizePricing(id: string): Promise<{ success: boolean; data?: PricingData; error?: string }> {
|
||||
export async function finalizePricing(id: string): Promise<{ success: boolean; data?: PricingData; 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/pricing/${id}/finalize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
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('[PricingActions] POST finalize response:', result);
|
||||
|
||||
@@ -432,19 +481,32 @@ export async function getPricingRevisions(priceId: string): Promise<{
|
||||
afterSnapshot: Record<string, unknown>;
|
||||
}>;
|
||||
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/pricing/${priceId}/revisions`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
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('[PricingActions] GET revisions response:', result);
|
||||
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Process, ProcessFormData, ClassificationRule } from '@/types/process';
|
||||
|
||||
// ============================================================================
|
||||
// API 헤더 생성
|
||||
// ============================================================================
|
||||
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 타입 정의
|
||||
// ============================================================================
|
||||
@@ -144,9 +128,8 @@ export async function getProcessList(params?: {
|
||||
q?: string;
|
||||
status?: string;
|
||||
process_type?: string;
|
||||
}): Promise<{ success: boolean; data?: { items: Process[]; total: number; page: number; totalPages: number }; error?: string }> {
|
||||
}): Promise<{ success: boolean; data?: { items: Process[]; total: number; page: number; totalPages: number }; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -155,11 +138,19 @@ export async function getProcessList(params?: {
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.process_type) searchParams.set('process_type', params.process_type);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.API_URL}/v1/processes?${searchParams.toString()}`,
|
||||
{ method: 'GET', headers, cache: 'no-store' }
|
||||
{ method: 'GET', cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PaginatedResponse<ApiProcess>> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -184,16 +175,21 @@ export async function getProcessList(params?: {
|
||||
/**
|
||||
* 공정 상세 조회
|
||||
*/
|
||||
export async function getProcessById(id: string): Promise<{ success: boolean; data?: Process; error?: string }> {
|
||||
export async function getProcessById(id: string): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes/${id}`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes/${id}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApiProcess> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -210,17 +206,23 @@ export async function getProcessById(id: string): Promise<{ success: boolean; da
|
||||
/**
|
||||
* 공정 생성
|
||||
*/
|
||||
export async function createProcess(data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string }> {
|
||||
export async function createProcess(data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApiProcess> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -237,17 +239,23 @@ export async function createProcess(data: ProcessFormData): Promise<{ success: b
|
||||
/**
|
||||
* 공정 수정
|
||||
*/
|
||||
export async function updateProcess(id: string, data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string }> {
|
||||
export async function updateProcess(id: string, data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes/${id}`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApiProcess> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -264,15 +272,20 @@ export async function updateProcess(id: string, data: ProcessFormData): Promise<
|
||||
/**
|
||||
* 공정 삭제
|
||||
*/
|
||||
export async function deleteProcess(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function deleteProcess(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes/${id}`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<null> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -289,16 +302,21 @@ export async function deleteProcess(id: string): Promise<{ success: boolean; err
|
||||
/**
|
||||
* 공정 일괄 삭제
|
||||
*/
|
||||
export async function deleteProcesses(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
export async function deleteProcesses(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body: JSON.stringify({ ids: ids.map((id) => parseInt(id, 10)) }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<{ deleted_count: number }> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -315,15 +333,20 @@ export async function deleteProcesses(ids: string[]): Promise<{ success: boolean
|
||||
/**
|
||||
* 공정 상태 토글
|
||||
*/
|
||||
export async function toggleProcessActive(id: string): Promise<{ success: boolean; data?: Process; error?: string }> {
|
||||
export async function toggleProcessActive(id: string): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes/${id}/toggle`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes/${id}/toggle`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '상태 변경에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApiProcess> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -344,16 +367,22 @@ export async function getProcessOptions(): Promise<{
|
||||
success: boolean;
|
||||
data?: Array<{ id: string; processCode: string; processName: string; processType: string; department: string }>;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes/options`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes/options`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '옵션 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<Array<{ id: number; process_code: string; process_name: string; process_type: string; department: string }>> =
|
||||
await response.json();
|
||||
|
||||
@@ -384,16 +413,22 @@ export async function getProcessStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: { total: number; active: number; inactive: number; byType: Record<string, number> };
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(`${process.env.API_URL}/v1/processes/stats`, {
|
||||
const { response, error } = await serverFetch(`${process.env.API_URL}/v1/processes/stats`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<{ total: number; active: number; inactive: number; by_type: Record<string, number> }> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
/**
|
||||
* 생산 현황판 서버 액션
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* serverFetch 마이그레이션 (2025-12-30)
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { WorkOrder, WorkerStatus, ProcessType, DashboardStats } from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 WorkOrderApiItem {
|
||||
id: number;
|
||||
@@ -101,9 +89,14 @@ export async function getDashboardData(processType?: ProcessType): Promise<{
|
||||
stats: DashboardStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const emptyResult = {
|
||||
success: false,
|
||||
workOrders: [] as WorkOrder[],
|
||||
workerStatus: [] as WorkerStatus[],
|
||||
stats: { total: 0, waiting: 0, inProgress: 0, completed: 0, urgent: 0, delayed: 0 },
|
||||
};
|
||||
|
||||
try {
|
||||
// 작업지시 목록 조회
|
||||
const params = new URLSearchParams({ per_page: '100' });
|
||||
if (processType && processType !== 'all') {
|
||||
@@ -113,19 +106,21 @@ export async function getDashboardData(processType?: ProcessType): Promise<{
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?${params.toString()}`;
|
||||
console.log('[ProductionDashboardActions] GET:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
// serverFetch handles 401 with redirect, so we only check for other errors
|
||||
if (error || !response) {
|
||||
console.warn('[ProductionDashboardActions] GET error:', error?.message);
|
||||
return {
|
||||
...emptyResult,
|
||||
error: error?.message || '데이터 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ProductionDashboardActions] GET error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
workOrders: [],
|
||||
workerStatus: [],
|
||||
stats: { total: 0, waiting: 0, inProgress: 0, completed: 0, urgent: 0, delayed: 0 },
|
||||
...emptyResult,
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
}
|
||||
@@ -134,10 +129,7 @@ export async function getDashboardData(processType?: ProcessType): Promise<{
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
workOrders: [],
|
||||
workerStatus: [],
|
||||
stats: { total: 0, waiting: 0, inProgress: 0, completed: 0, urgent: 0, delayed: 0 },
|
||||
...emptyResult,
|
||||
error: result.message || '데이터 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
@@ -192,10 +184,7 @@ export async function getDashboardData(processType?: ProcessType): Promise<{
|
||||
} catch (error) {
|
||||
console.error('[ProductionDashboardActions] getDashboardData error:', error);
|
||||
return {
|
||||
success: false,
|
||||
workOrders: [],
|
||||
workerStatus: [],
|
||||
stats: { total: 0, waiting: 0, inProgress: 0, completed: 0, urgent: 0, delayed: 0 },
|
||||
...emptyResult,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
WorkOrder,
|
||||
WorkOrderStats,
|
||||
@@ -32,19 +32,6 @@ import {
|
||||
transformStatsApiToFrontend,
|
||||
} from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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;
|
||||
@@ -68,8 +55,13 @@ export async function getWorkOrders(params?: {
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
const emptyResponse = {
|
||||
success: false,
|
||||
data: [] as WorkOrder[],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -89,29 +81,22 @@ export async function getWorkOrders(params?: {
|
||||
|
||||
console.log('[WorkOrderActions] GET work-orders:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
return { ...emptyResponse, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET work-orders error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
return { ...emptyResponse, 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 },
|
||||
...emptyResponse,
|
||||
error: result.message || '작업지시 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
@@ -138,12 +123,7 @@ export async function getWorkOrders(params?: {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] getWorkOrders error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { ...emptyResponse, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,23 +134,19 @@ export async function getWorkOrderStats(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/stats`;
|
||||
|
||||
console.log('[WorkOrderActions] GET stats:', url);
|
||||
|
||||
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 || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET stats error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -190,10 +166,7 @@ export async function getWorkOrderStats(): Promise<{
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] getWorkOrderStats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,23 +177,19 @@ export async function getWorkOrderById(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}`;
|
||||
|
||||
console.log('[WorkOrderActions] GET work-order:', url);
|
||||
|
||||
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 || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[WorkOrderActions] GET work-order error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
return { success: false, error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -238,10 +207,7 @@ export async function getWorkOrderById(id: string): Promise<{
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] getWorkOrderById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +220,6 @@ export async function createWorkOrder(
|
||||
}
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = {
|
||||
...transformFrontendToApi(data),
|
||||
sales_order_id: data.salesOrderId,
|
||||
@@ -264,15 +229,18 @@ export async function createWorkOrder(
|
||||
|
||||
console.log('[WorkOrderActions] POST work-order request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] POST work-order response:', result);
|
||||
|
||||
@@ -289,10 +257,7 @@ export async function createWorkOrder(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] createWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,20 +267,22 @@ export async function updateWorkOrder(
|
||||
data: Partial<WorkOrder>
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[WorkOrderActions] PUT work-order request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PUT work-order response:', result);
|
||||
|
||||
@@ -332,26 +299,22 @@ export async function updateWorkOrder(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] updateWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 작업지시 삭제 =====
|
||||
export async function deleteWorkOrder(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/work-orders/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
}
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] DELETE work-order response:', result);
|
||||
|
||||
@@ -365,10 +328,7 @@ export async function deleteWorkOrder(id: string): Promise<{ success: boolean; e
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] deleteWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,19 +338,20 @@ export async function updateWorkOrderStatus(
|
||||
status: WorkOrderStatus
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
console.log('[WorkOrderActions] PATCH status request:', { status });
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}/status`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ status }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PATCH status response:', result);
|
||||
|
||||
@@ -407,10 +368,7 @@ export async function updateWorkOrderStatus(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] updateWorkOrderStatus error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,22 +379,23 @@ export async function assignWorkOrder(
|
||||
teamId?: number
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const body: { assignee_id: number; team_id?: number } = { assignee_id: assigneeId };
|
||||
if (teamId) body.team_id = teamId;
|
||||
|
||||
console.log('[WorkOrderActions] PATCH assign request:', body);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}/assign`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PATCH assign response:', result);
|
||||
|
||||
@@ -453,10 +412,7 @@ export async function assignWorkOrder(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] assignWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,19 +422,20 @@ export async function toggleBendingField(
|
||||
field: string
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
console.log('[WorkOrderActions] PATCH bending toggle request:', { field });
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}/bending/toggle`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ field }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PATCH bending toggle response:', result);
|
||||
|
||||
@@ -495,10 +452,7 @@ export async function toggleBendingField(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] toggleBendingField error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,19 +466,20 @@ export async function addWorkOrderIssue(
|
||||
}
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
console.log('[WorkOrderActions] POST issue request:', data);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}/issues`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] POST issue response:', result);
|
||||
|
||||
@@ -541,10 +496,7 @@ export async function addWorkOrderIssue(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] addWorkOrderIssue error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,18 +506,17 @@ export async function resolveWorkOrderIssue(
|
||||
issueId: string
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
console.log('[WorkOrderActions] PATCH issue resolve:', { workOrderId, issueId });
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/issues/${issueId}/resolve`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
}
|
||||
{ method: 'PATCH' }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PATCH issue resolve response:', result);
|
||||
|
||||
@@ -582,10 +533,7 @@ export async function resolveWorkOrderIssue(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] resolveWorkOrderIssue error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +558,6 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// 작업지시 생성 가능한 상태만 조회 (예: 회계확인 완료)
|
||||
@@ -623,19 +570,15 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
|
||||
console.log('[WorkOrderActions] GET sales-orders for work-order:', url);
|
||||
|
||||
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, data: [], error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET sales-orders error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
return { success: false, data: [], error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -677,11 +620,7 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] getSalesOrdersForWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,24 +645,19 @@ export async function getDepartmentsWithUsers(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/tree?with_users=1`;
|
||||
|
||||
console.log('[WorkOrderActions] GET departments with users:', url);
|
||||
|
||||
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, data: [], error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET departments error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
return { success: false, data: [], error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -765,10 +699,6 @@ export async function getDepartmentsWithUsers(): Promise<{
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderActions] getDepartmentsWithUsers error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - PATCH /api/v1/work-results/{id}/packaging - 포장 상태 토글
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ProcessType } from '../WorkOrders/types';
|
||||
import type {
|
||||
WorkResult,
|
||||
@@ -28,19 +28,6 @@ import {
|
||||
transformStatsApiToFrontend,
|
||||
} from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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;
|
||||
@@ -68,7 +55,6 @@ export async function getWorkResults(params?: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -93,12 +79,20 @@ export async function getWorkResults(params?: {
|
||||
|
||||
console.log('[WorkResultActions] GET work-results:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.warn('[WorkResultActions] GET work-results error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkResultActions] GET work-results error:', response.status);
|
||||
return {
|
||||
@@ -160,7 +154,6 @@ export async function getWorkResultStats(params?: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.workDateFrom) searchParams.set('work_date_from', params.workDateFrom);
|
||||
@@ -174,12 +167,18 @@ export async function getWorkResultStats(params?: {
|
||||
|
||||
console.log('[WorkResultActions] GET stats:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.warn('[WorkResultActions] GET stats error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkResultActions] GET stats error:', response.status);
|
||||
return {
|
||||
@@ -219,17 +218,22 @@ export async function getWorkResultById(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-results/${id}`;
|
||||
|
||||
console.log('[WorkResultActions] GET work-result:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[WorkResultActions] GET work-result error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[WorkResultActions] GET work-result error:', response.status);
|
||||
return {
|
||||
@@ -281,8 +285,6 @@ export async function createWorkResult(data: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const apiData: Record<string, unknown> = {
|
||||
work_order_id: data.workOrderId,
|
||||
lot_no: data.lotNo,
|
||||
@@ -302,15 +304,22 @@ export async function createWorkResult(data: {
|
||||
|
||||
console.log('[WorkResultActions] POST work-result request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-results`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[WorkResultActions] POST work-result error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkResultActions] POST work-result response:', result);
|
||||
|
||||
@@ -344,20 +353,26 @@ export async function updateWorkResult(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[WorkResultActions] PUT work-result request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-results/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[WorkResultActions] PUT work-result error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkResultActions] PUT work-result response:', result);
|
||||
|
||||
@@ -387,16 +402,21 @@ export async function deleteWorkResult(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/work-results/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[WorkResultActions] DELETE work-result error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkResultActions] DELETE work-result response:', result);
|
||||
|
||||
@@ -424,18 +444,23 @@ export async function toggleInspection(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
console.log('[WorkResultActions] PATCH inspection toggle:', id);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-results/${id}/inspection`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[WorkResultActions] PATCH inspection error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkResultActions] PATCH inspection response:', result);
|
||||
|
||||
@@ -466,18 +491,23 @@ export async function togglePackaging(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
console.log('[WorkResultActions] PATCH packaging toggle:', id);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-results/${id}/packaging`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[WorkResultActions] PATCH packaging error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkResultActions] PATCH packaging response:', result);
|
||||
|
||||
|
||||
@@ -7,22 +7,9 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 WorkOrderApiItem {
|
||||
id: number;
|
||||
@@ -97,18 +84,21 @@ export async function getMyWorkOrders(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
// 작업 대기 + 작업중 상태만 조회 (완료 제외)
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&assigned_to_me=1`;
|
||||
|
||||
console.log('[WorkerScreenActions] GET my work orders:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
console.warn('[WorkerScreenActions] GET error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkerScreenActions] GET error:', response.status);
|
||||
@@ -156,14 +146,11 @@ export async function completeWorkOrder(
|
||||
materials?: { materialId: number; quantity: number; lotNo?: string }[]
|
||||
): Promise<{ success: boolean; lotNo?: string; error?: string }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
// 상태를 completed로 변경
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${id}/status`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
status: 'completed',
|
||||
materials,
|
||||
@@ -171,6 +158,13 @@ export async function completeWorkOrder(
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkerScreenActions] Complete response:', result);
|
||||
|
||||
@@ -215,18 +209,21 @@ export async function getMaterialsForWorkOrder(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
// 작업지시 BOM 기준 자재 목록 조회
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/materials`;
|
||||
|
||||
console.log('[WorkerScreenActions] GET materials for work order:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
console.warn('[WorkerScreenActions] GET materials error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkerScreenActions] GET materials error:', response.status);
|
||||
@@ -284,17 +281,21 @@ export async function registerMaterialInput(
|
||||
materialIds: number[]
|
||||
): 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/work-orders/${workOrderId}/material-inputs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ material_ids: materialIds }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkerScreenActions] Register material input response:', result);
|
||||
|
||||
@@ -325,17 +326,21 @@ export async function reportIssue(
|
||||
}
|
||||
): 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/work-orders/${workOrderId}/issues`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkerScreenActions] Report issue response:', result);
|
||||
|
||||
@@ -385,17 +390,20 @@ export async function getProcessSteps(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/process-steps`;
|
||||
|
||||
console.log('[WorkerScreenActions] GET process steps:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
console.warn('[WorkerScreenActions] GET process steps error:', error?.message);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkerScreenActions] GET process steps error:', response.status);
|
||||
@@ -471,17 +479,21 @@ export async function requestInspection(
|
||||
stepId: 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/work-orders/${workOrderId}/process-steps/${stepId}/inspection-request`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '네트워크 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkerScreenActions] Inspection request response:', result);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/**
|
||||
* 검사 관리 Server Actions
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* fetch-wrapper 마이그레이션 완료 (2025-12-30)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/inspections - 목록 조회
|
||||
@@ -14,7 +15,7 @@
|
||||
* - PATCH /api/v1/inspections/{id}/complete - 검사 완료 처리
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
Inspection,
|
||||
InspectionStats,
|
||||
@@ -23,19 +24,6 @@ import type {
|
||||
InspectionItem,
|
||||
} from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 InspectionApiItem {
|
||||
id: number;
|
||||
@@ -190,9 +178,9 @@ export async function getInspections(params?: {
|
||||
data: Inspection[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -212,12 +200,29 @@ export async function getInspections(params?: {
|
||||
|
||||
console.log('[InspectionActions] GET inspections:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '검사 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[InspectionActions] GET inspections error:', response.status);
|
||||
return {
|
||||
@@ -277,9 +282,9 @@ export async function getInspectionStats(params?: {
|
||||
success: boolean;
|
||||
data?: InspectionStats;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
|
||||
@@ -293,12 +298,25 @@ export async function getInspectionStats(params?: {
|
||||
|
||||
console.log('[InspectionActions] GET stats:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: '통계 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[InspectionActions] GET stats error:', response.status);
|
||||
return {
|
||||
@@ -341,19 +359,32 @@ export async function getInspectionById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Inspection;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspections/${id}`;
|
||||
|
||||
console.log('[InspectionActions] GET inspection:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: '검사 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[InspectionActions] GET inspection error:', response.status);
|
||||
return {
|
||||
@@ -399,10 +430,9 @@ export async function createInspection(data: {
|
||||
success: boolean;
|
||||
data?: Inspection;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const apiData: Record<string, unknown> = {
|
||||
inspection_type: data.inspectionType,
|
||||
lot_no: data.lotNo,
|
||||
@@ -425,15 +455,29 @@ export async function createInspection(data: {
|
||||
|
||||
console.log('[InspectionActions] POST inspection request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspections`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[InspectionActions] POST inspection response:', result);
|
||||
|
||||
@@ -470,10 +514,9 @@ export async function updateInspection(
|
||||
success: boolean;
|
||||
data?: Inspection;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.items) {
|
||||
@@ -503,15 +546,29 @@ export async function updateInspection(
|
||||
|
||||
console.log('[InspectionActions] PUT inspection request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspections/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[InspectionActions] PUT inspection response:', result);
|
||||
|
||||
@@ -539,18 +596,31 @@ export async function updateInspection(
|
||||
export async function deleteInspection(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/inspections/${id}`,
|
||||
{
|
||||
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();
|
||||
console.log('[InspectionActions] DELETE inspection response:', result);
|
||||
|
||||
@@ -582,10 +652,9 @@ export async function completeInspection(
|
||||
success: boolean;
|
||||
data?: Inspection;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const apiData = {
|
||||
result: data.result === '합격' ? 'pass' : 'fail',
|
||||
opinion: data.opinion,
|
||||
@@ -593,15 +662,29 @@ export async function completeInspection(
|
||||
|
||||
console.log('[InspectionActions] PATCH complete request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspections/${id}/complete`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[InspectionActions] PATCH complete response:', result);
|
||||
|
||||
@@ -623,4 +706,4 @@ export async function completeInspection(
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
Quote,
|
||||
QuoteApiData,
|
||||
@@ -30,19 +30,6 @@ import type {
|
||||
} from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi } from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 interface PaginationMeta {
|
||||
currentPage: number;
|
||||
@@ -57,9 +44,9 @@ export async function getQuotes(params?: QuoteListParams): Promise<{
|
||||
data: Quote[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -78,12 +65,29 @@ export async function getQuotes(params?: QuoteListParams): Promise<{
|
||||
|
||||
console.log('[QuoteActions] GET quotes:', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '견적 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[QuoteActions] GET quotes error:', response.status);
|
||||
return {
|
||||
@@ -142,18 +146,29 @@ export async function getQuoteById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Quote;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: '견적 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[QuoteActions] GET quote error:', response.status);
|
||||
@@ -188,21 +203,33 @@ export async function getQuoteById(id: string): Promise<{
|
||||
// ===== 견적 등록 =====
|
||||
export async function createQuote(
|
||||
data: Partial<Quote>
|
||||
): Promise<{ success: boolean; data?: Quote; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: Quote; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[QuoteActions] POST quote request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
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('[QuoteActions] POST quote response:', result);
|
||||
@@ -231,21 +258,33 @@ export async function createQuote(
|
||||
export async function updateQuote(
|
||||
id: string,
|
||||
data: Partial<Quote>
|
||||
): Promise<{ success: boolean; data?: Quote; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: Quote; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[QuoteActions] PUT quote request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
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('[QuoteActions] PUT quote response:', result);
|
||||
@@ -271,17 +310,28 @@ export async function updateQuote(
|
||||
}
|
||||
|
||||
// ===== 견적 삭제 =====
|
||||
export async function deleteQuote(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function deleteQuote(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
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('[QuoteActions] DELETE quote response:', result);
|
||||
@@ -304,18 +354,29 @@ export async function deleteQuote(id: string): Promise<{ success: boolean; error
|
||||
}
|
||||
|
||||
// ===== 견적 일괄 삭제 =====
|
||||
export async function bulkDeleteQuotes(ids: string[]): Promise<{ success: boolean; error?: string }> {
|
||||
export async function bulkDeleteQuotes(ids: string[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/bulk`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/bulk`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)) }),
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ ids: ids.map(id => parseInt(id, 10)) }),
|
||||
});
|
||||
|
||||
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('[QuoteActions] BULK DELETE quotes response:', result);
|
||||
@@ -342,17 +403,29 @@ export async function finalizeQuote(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Quote;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/finalize`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/finalize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
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('[QuoteActions] POST finalize response:', result);
|
||||
@@ -382,17 +455,29 @@ export async function cancelFinalizeQuote(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Quote;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/cancel-finalize`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/cancel-finalize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
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('[QuoteActions] POST cancel-finalize response:', result);
|
||||
@@ -423,17 +508,29 @@ export async function convertQuoteToOrder(id: string): Promise<{
|
||||
data?: Quote;
|
||||
orderId?: string;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/convert`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/convert`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
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('[QuoteActions] POST convert response:', result);
|
||||
@@ -464,18 +561,29 @@ export async function getQuoteNumberPreview(): Promise<{
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/number/preview`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/number/preview`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: '견적번호 미리보기에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -504,17 +612,29 @@ export async function generateQuotePdf(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/pdf`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/pdf`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF 생성에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
@@ -542,18 +662,29 @@ export async function generateQuotePdf(id: string): Promise<{
|
||||
export async function sendQuoteEmail(
|
||||
id: string,
|
||||
emailData: { email: string; subject?: string; message?: 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/quotes/${id}/send/email`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/send/email`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(emailData),
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(emailData),
|
||||
});
|
||||
|
||||
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('[QuoteActions] POST send email response:', result);
|
||||
@@ -579,18 +710,29 @@ export async function sendQuoteEmail(
|
||||
export async function sendQuoteKakao(
|
||||
id: string,
|
||||
kakaoData: { phone: string; templateId?: 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/quotes/${id}/send/kakao`;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}/send/kakao`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(kakaoData),
|
||||
}
|
||||
);
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(kakaoData),
|
||||
});
|
||||
|
||||
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('[QuoteActions] POST send kakao response:', result);
|
||||
@@ -630,6 +772,7 @@ export async function getQuotesSummary(params?: {
|
||||
conversionRate: number;
|
||||
};
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
// 목록 조회를 통해 통계 계산 (별도 API 없는 경우)
|
||||
@@ -643,6 +786,7 @@ export async function getQuotesSummary(params?: {
|
||||
return {
|
||||
success: false,
|
||||
error: listResult.error,
|
||||
__authError: listResult.__authError,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ComprehensiveAnalysisData } from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 TodayIssueItemApi {
|
||||
id: string;
|
||||
@@ -139,9 +126,9 @@ export async function getComprehensiveAnalysis(params?: {
|
||||
success: boolean;
|
||||
data?: ComprehensiveAnalysisData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.date) searchParams.set('date', params.date);
|
||||
@@ -149,23 +136,22 @@ export async function getComprehensiveAnalysis(params?: {
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/comprehensive-analysis${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ComprehensiveAnalysisActions] GET 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) {
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '종합 분석 조회에 실패했습니다.',
|
||||
@@ -191,27 +177,26 @@ export async function getComprehensiveAnalysis(params?: {
|
||||
export async function approveIssue(issueId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${issueId}/approve`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ComprehensiveAnalysisActions] POST approve 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) {
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '승인에 실패했습니다.',
|
||||
@@ -232,28 +217,27 @@ export async function approveIssue(issueId: string): Promise<{
|
||||
export async function rejectIssue(issueId: string, reason?: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${issueId}/reject`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ comment: reason }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ComprehensiveAnalysisActions] POST reject 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) {
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '반려에 실패했습니다.',
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 || '',
|
||||
};
|
||||
}
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ===== 계정 탈퇴 =====
|
||||
export async function withdrawAccount(): 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/users/withdraw`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -55,19 +55,32 @@ export async function withdrawAccount(): Promise<{
|
||||
export async function suspendTenant(): 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/tenants/suspend`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Account, AccountFormData, AccountStatus } from './types';
|
||||
import { BANK_LABELS } from './types';
|
||||
|
||||
@@ -39,19 +39,6 @@ interface ApiSingleResponse {
|
||||
data: BankAccountApiData;
|
||||
}
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 → Frontend =====
|
||||
function transformApiToFrontend(apiData: BankAccountApiData): Account {
|
||||
return {
|
||||
@@ -91,9 +78,9 @@ export async function getBankAccounts(params?: {
|
||||
data?: Account[];
|
||||
meta?: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', params.page.toString());
|
||||
@@ -102,12 +89,23 @@ export async function getBankAccounts(params?: {
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?${searchParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '계좌 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiListResponse = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -127,18 +125,29 @@ export async function getBankAccount(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: Account;
|
||||
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/bank-accounts/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '계좌 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiSingleResponse = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -158,20 +167,31 @@ export async function createBankAccount(data: AccountFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Account;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
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/bank-accounts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '계좌 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiSingleResponse = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -194,20 +214,31 @@ export async function updateBankAccount(
|
||||
success: boolean;
|
||||
data?: Account;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
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/bank-accounts/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '계좌 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiSingleResponse = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -226,17 +257,28 @@ export async function updateBankAccount(
|
||||
export async function deleteBankAccount(id: number): 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/bank-accounts/${id}`,
|
||||
{
|
||||
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) {
|
||||
@@ -255,17 +297,28 @@ export async function toggleBankAccountStatus(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: Account;
|
||||
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/bank-accounts/${id}/toggle`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '상태 변경에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiSingleResponse = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -285,17 +338,28 @@ export async function setPrimaryBankAccount(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: Account;
|
||||
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/bank-accounts/${id}/set-primary`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '대표 계좌 설정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiSingleResponse = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://sam.kr:8080';
|
||||
|
||||
// ===== API Helper =====
|
||||
async function getAuthHeaders() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
};
|
||||
}
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
@@ -79,21 +65,30 @@ export async function getAttendanceSetting(): Promise<{
|
||||
success: boolean;
|
||||
data?: AttendanceSettingFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/attendance`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorData = await response?.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData?.message || `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,21 +116,30 @@ export async function updateAttendanceSetting(
|
||||
success: boolean;
|
||||
data?: AttendanceSettingFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/attendance`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(transformToApi(data)),
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(transformToApi(data)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorData = await response?.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData?.message || `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { CompanyFormData } from './types';
|
||||
|
||||
// API 응답 타입
|
||||
@@ -32,19 +32,6 @@ interface TenantApiData {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// API 헤더 생성
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 정보 조회
|
||||
*/
|
||||
@@ -52,18 +39,29 @@ export async function getCompanyInfo(): Promise<{
|
||||
success: boolean;
|
||||
data?: CompanyFormData & { tenantId: number };
|
||||
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/tenants`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -90,20 +88,31 @@ export async function updateCompanyInfo(
|
||||
success: boolean;
|
||||
data?: CompanyFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(tenantId, data);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { LeavePolicySettings } from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 LeavePolicyApi {
|
||||
id: number;
|
||||
@@ -70,22 +57,29 @@ export async function getLeavePolicy(): Promise<{
|
||||
success: boolean;
|
||||
data?: LeavePolicySettings;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/leave-policy`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[LeavePolicyActions] GET error:', response.status);
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[LeavePolicyActions] GET error:', response?.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,24 +112,30 @@ export async function updateLeavePolicy(data: Partial<LeavePolicySettings>): Pro
|
||||
success: boolean;
|
||||
data?: LeavePolicySettings;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/leave-policy`;
|
||||
|
||||
const apiData = transformToApi(data);
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[LeavePolicyActions] PUT error:', response.status);
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[LeavePolicyActions] PUT error:', response?.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { NotificationSettings } from './types';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 getNotificationSettings(): Promise<{
|
||||
success: boolean;
|
||||
data: NotificationSettings;
|
||||
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/settings/notifications`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[NotificationActions] GET settings error:', response.status);
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: DEFAULT_NOTIFICATION_SETTINGS,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.warn('[NotificationActions] GET settings error:', response?.status);
|
||||
return {
|
||||
success: true,
|
||||
data: DEFAULT_NOTIFICATION_SETTINGS,
|
||||
@@ -69,20 +63,33 @@ export async function getNotificationSettings(): Promise<{
|
||||
// ===== 알림 설정 저장 =====
|
||||
export async function saveNotificationSettings(
|
||||
settings: NotificationSettings
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(settings);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/notifications`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { PaymentApiData, PaymentHistory } from './types';
|
||||
import { transformApiToFrontend } from './utils';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 getPayments(params?: {
|
||||
page?: number;
|
||||
@@ -35,10 +22,9 @@ export async function getPayments(params?: {
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
// 쿼리 파라미터 생성
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.page) searchParams.append('page', String(params.page));
|
||||
@@ -51,12 +37,30 @@ export async function getPayments(params?: {
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/payments${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
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 (!response.ok || !result.success) {
|
||||
@@ -144,19 +148,32 @@ export async function getPaymentStatement(id: string): Promise<{
|
||||
total: number;
|
||||
};
|
||||
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/payments/${id}/statement`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Popup, PopupFormData } from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils';
|
||||
|
||||
@@ -25,25 +25,6 @@ interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 함수
|
||||
// ============================================
|
||||
@@ -65,7 +46,6 @@ export async function getPopups(params?: {
|
||||
status?: string;
|
||||
}): Promise<Popup[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) searchParams.set('page', String(params.page));
|
||||
@@ -76,12 +56,16 @@ export async function getPopups(params?: {
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups?${searchParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[PopupActions] GET list error:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PopupActions] GET list error:', response.status);
|
||||
return [];
|
||||
@@ -106,17 +90,19 @@ export async function getPopups(params?: {
|
||||
*/
|
||||
export async function getPopupById(id: string): Promise<Popup | null> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[PopupActions] GET popup error:', error?.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PopupActions] GET popup error:', response.status);
|
||||
return null;
|
||||
@@ -140,22 +126,35 @@ export async function getPopupById(id: string): Promise<Popup | null> {
|
||||
*/
|
||||
export async function createPopup(
|
||||
data: PopupFormData
|
||||
): Promise<{ success: boolean; data?: Popup; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: Popup; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[PopupActions] POST popup request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[PopupActions] POST popup response:', result);
|
||||
|
||||
@@ -185,22 +184,35 @@ export async function createPopup(
|
||||
export async function updatePopup(
|
||||
id: string,
|
||||
data: PopupFormData
|
||||
): Promise<{ success: boolean; data?: Popup; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: Popup; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const apiData = transformFrontendToApi(data);
|
||||
|
||||
console.log('[PopupActions] PUT popup request:', apiData);
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(apiData),
|
||||
}
|
||||
);
|
||||
|
||||
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('[PopupActions] PUT popup response:', result);
|
||||
|
||||
@@ -227,18 +239,30 @@ export async function updatePopup(
|
||||
/**
|
||||
* 팝업 삭제
|
||||
*/
|
||||
export async function deletePopup(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function deletePopup(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/popups/${id}`,
|
||||
{
|
||||
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();
|
||||
console.log('[PopupActions] DELETE popup response:', result);
|
||||
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { SubscriptionApiData, UsageApiData, SubscriptionInfo } from './types';
|
||||
import { transformApiToFrontend } from './utils';
|
||||
|
||||
// ===== API 헤더 생성 =====
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
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 getCurrentSubscription(): Promise<{
|
||||
success: boolean;
|
||||
data: SubscriptionApiData | null;
|
||||
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/subscriptions/current`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: '구독 정보를 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -64,19 +66,34 @@ export async function getUsage(): Promise<{
|
||||
success: boolean;
|
||||
data: UsageApiData | null;
|
||||
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/subscriptions/usage`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: '사용량 정보를 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -108,19 +125,32 @@ export async function cancelSubscription(
|
||||
): 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/subscriptions/${id}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ reason }),
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -147,19 +177,32 @@ export async function requestDataExport(
|
||||
success: boolean;
|
||||
data?: { id: number; status: string };
|
||||
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/subscriptions/export`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ export_type: exportType }),
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://sam.kr:8080';
|
||||
|
||||
// ===== API Helper =====
|
||||
async function getAuthHeaders() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
// API 응답 타입
|
||||
@@ -99,21 +87,27 @@ export async function getWorkSetting(): Promise<{
|
||||
success: boolean;
|
||||
data?: WorkSettingFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/work`, {
|
||||
const { response, error } = await serverFetch(`${API_BASE_URL}/api/v1/settings/work`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorData = await response?.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData?.message || `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,21 +135,27 @@ export async function updateWorkSetting(
|
||||
success: boolean;
|
||||
data?: WorkSettingFormData;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/work`, {
|
||||
const { response, error } = await serverFetch(`${API_BASE_URL}/api/v1/settings/work`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(transformToApi(data)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorData = await response?.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData?.message || `API 오류: ${response?.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
140
src/contexts/ApiErrorContext.tsx
Normal file
140
src/contexts/ApiErrorContext.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* API 에러 컨텍스트
|
||||
*
|
||||
* Server Action 결과에서 인증 에러를 감지하고
|
||||
* 자동으로 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isAuthError, isApiError, type ApiErrorResponse } from '@/lib/api/errors';
|
||||
import { callLogoutAPI } from '@/lib/auth/logout';
|
||||
|
||||
interface ApiErrorContextType {
|
||||
/**
|
||||
* API 응답을 체크하고 인증 에러면 로그인 페이지로 이동
|
||||
* @returns true면 에러가 있음 (호출자는 early return 해야 함)
|
||||
*/
|
||||
checkAuthError: <T>(response: T) => response is ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* 수동으로 인증 에러 처리 (로그아웃 후 로그인 페이지 이동)
|
||||
*/
|
||||
handleAuthError: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ApiErrorContext = createContext<ApiErrorContextType | null>(null);
|
||||
|
||||
/**
|
||||
* API 에러 Provider
|
||||
*
|
||||
* Protected Layout에 추가하여 모든 하위 페이지에서 사용
|
||||
*/
|
||||
export function ApiErrorProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const isRedirecting = useRef(false);
|
||||
|
||||
/**
|
||||
* 인증 에러 처리 - 로그아웃 후 로그인 페이지 이동
|
||||
*/
|
||||
const handleAuthError = useCallback(async () => {
|
||||
// 중복 리다이렉트 방지
|
||||
if (isRedirecting.current) return;
|
||||
isRedirecting.current = true;
|
||||
|
||||
console.warn('[ApiErrorContext] 인증 에러 감지 - 로그인 페이지로 이동');
|
||||
|
||||
try {
|
||||
// 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
await callLogoutAPI();
|
||||
} catch (error) {
|
||||
console.error('[ApiErrorContext] 로그아웃 API 호출 실패:', error);
|
||||
}
|
||||
|
||||
// 로그인 페이지로 이동
|
||||
window.location.href = '/login';
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* API 응답에서 인증 에러 체크
|
||||
* 인증 에러면 자동으로 로그인 페이지 이동
|
||||
*/
|
||||
const checkAuthError = useCallback(<T,>(response: T): response is ApiErrorResponse => {
|
||||
if (isAuthError(response)) {
|
||||
handleAuthError();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isApiError(response)) {
|
||||
console.warn('[ApiErrorContext] API 에러:', response.message);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [handleAuthError]);
|
||||
|
||||
return (
|
||||
<ApiErrorContext.Provider value={{ checkAuthError, handleAuthError }}>
|
||||
{children}
|
||||
</ApiErrorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 에러 컨텍스트 훅
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { checkAuthError } = useApiError();
|
||||
*
|
||||
* const result = await getEmployees();
|
||||
* if (checkAuthError(result)) return; // 인증 에러면 자동 리다이렉트
|
||||
*
|
||||
* // 정상 데이터 처리...
|
||||
* ```
|
||||
*/
|
||||
export function useApiError() {
|
||||
const context = useContext(ApiErrorContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useApiError must be used within ApiErrorProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action 결과 래퍼
|
||||
*
|
||||
* Server Action 호출 결과를 자동으로 체크하고
|
||||
* 인증 에러면 null 반환 + 리다이렉트
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { withAuthCheck } = useApiError();
|
||||
*
|
||||
* const employees = await withAuthCheck(getEmployees());
|
||||
* if (!employees) return; // 에러 발생 시 null
|
||||
*
|
||||
* // 정상 데이터 사용...
|
||||
* ```
|
||||
*/
|
||||
export function useWithAuthCheck() {
|
||||
const { checkAuthError } = useApiError();
|
||||
|
||||
const withAuthCheck = useCallback(async <T,>(
|
||||
promise: Promise<T>
|
||||
): Promise<T | null> => {
|
||||
const result = await promise;
|
||||
|
||||
if (checkAuthError(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [checkAuthError]);
|
||||
|
||||
return { withAuthCheck };
|
||||
}
|
||||
79
src/lib/api/errors.ts
Normal file
79
src/lib/api/errors.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* API 에러 타입 정의
|
||||
*
|
||||
* 전역 에러 핸들링을 위한 커스텀 에러 클래스들
|
||||
*/
|
||||
|
||||
/**
|
||||
* 인증 에러 (401 Unauthorized)
|
||||
* - 토큰 만료
|
||||
* - 세션 만료
|
||||
* - 인증 실패
|
||||
*/
|
||||
export class AuthError extends Error {
|
||||
public readonly status = 401;
|
||||
public readonly code = 'AUTH_ERROR';
|
||||
|
||||
constructor(message: string = '인증이 만료되었습니다. 다시 로그인해주세요.') {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 에러 응답 타입
|
||||
* Server Action에서 클라이언트로 전달되는 에러 형식
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
__error: true;
|
||||
__authError?: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 에러 응답 생성 헬퍼
|
||||
* Server Action에서 401 발생 시 반환할 객체
|
||||
*/
|
||||
export function createAuthErrorResponse(message?: string): ApiErrorResponse {
|
||||
return {
|
||||
__error: true,
|
||||
__authError: true,
|
||||
status: 401,
|
||||
message: message || '인증이 만료되었습니다. 다시 로그인해주세요.',
|
||||
code: 'AUTH_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 API 에러 응답 생성 헬퍼
|
||||
*/
|
||||
export function createErrorResponse(status: number, message: string, code?: string): ApiErrorResponse {
|
||||
return {
|
||||
__error: true,
|
||||
__authError: status === 401,
|
||||
status,
|
||||
message,
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답이 에러인지 확인하는 타입 가드
|
||||
*/
|
||||
export function isApiError(response: unknown): response is ApiErrorResponse {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'__error' in response &&
|
||||
(response as ApiErrorResponse).__error === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답이 인증 에러인지 확인하는 타입 가드
|
||||
*/
|
||||
export function isAuthError(response: unknown): response is ApiErrorResponse {
|
||||
return isApiError(response) && response.__authError === true;
|
||||
}
|
||||
212
src/lib/api/fetch-wrapper.ts
Normal file
212
src/lib/api/fetch-wrapper.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 전역 Fetch Wrapper
|
||||
*
|
||||
* 모든 Server Actions에서 사용할 공통 fetch 함수
|
||||
* - 401 에러 자동 감지 및 토큰 자동 갱신
|
||||
* - 일관된 에러 처리
|
||||
* - 헤더 자동 설정
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
||||
import { refreshAccessToken } from './refresh-token';
|
||||
|
||||
/**
|
||||
* 새 토큰을 쿠키에 저장
|
||||
*/
|
||||
async function setNewTokenCookies(tokens: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (tokens.accessToken) {
|
||||
cookieStore.set('access_token', tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: tokens.expiresIn || 7200,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.refreshToken) {
|
||||
cookieStore.set('refresh_token', tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 604800, // 7 days
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 헤더 생성 (Server Side)
|
||||
*/
|
||||
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = token || cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': accessToken ? `Bearer ${accessToken}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action용 Fetch Wrapper
|
||||
*
|
||||
* 🔄 토큰 갱신 로직:
|
||||
* 1. 현재 access_token으로 요청
|
||||
* 2. 401 응답 시 → refresh_token으로 새 토큰 발급
|
||||
* 3. 새 토큰으로 원래 요청 재시도
|
||||
* 4. 재시도도 실패하면 → 로그인 페이지로 리다이렉트
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
* if (error) return error; // 에러 응답 반환 (클라이언트에서 처리)
|
||||
* // response 사용...
|
||||
* ```
|
||||
*/
|
||||
export async function serverFetch(
|
||||
url: string,
|
||||
options?: RequestInit & { skipAuthCheck?: boolean }
|
||||
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value;
|
||||
|
||||
const headers = await getServerApiHeaders();
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: options?.cache || 'no-store',
|
||||
});
|
||||
|
||||
// 🔄 401 응답 시 토큰 갱신 후 재시도
|
||||
if (response.status === 401 && !options?.skipAuthCheck && refreshToken) {
|
||||
console.log('🔄 [serverFetch] Got 401, attempting token refresh...');
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch');
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [serverFetch] Token refreshed, retrying original request...');
|
||||
|
||||
// 새 토큰을 쿠키에 저장
|
||||
await setNewTokenCookies(refreshResult);
|
||||
|
||||
// 새 토큰으로 원래 요청 재시도
|
||||
const newHeaders = await getServerApiHeaders(refreshResult.accessToken);
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...newHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: options?.cache || 'no-store',
|
||||
});
|
||||
|
||||
console.log('🔵 [serverFetch] Retry response status:', response.status);
|
||||
|
||||
// 재시도도 401이면 로그인으로
|
||||
if (response.status === 401) {
|
||||
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
|
||||
redirect('/login');
|
||||
}
|
||||
} else {
|
||||
// 리프레시 실패 → 로그인 페이지로
|
||||
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
||||
redirect('/login');
|
||||
}
|
||||
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
||||
// refresh_token이 없는 경우
|
||||
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
// 403 Forbidden
|
||||
if (response.status === 403) {
|
||||
console.warn(`[serverFetch] 403 Forbidden: ${url}`);
|
||||
return {
|
||||
response: null,
|
||||
error: createErrorResponse(403, '접근 권한이 없습니다.', 'FORBIDDEN'),
|
||||
};
|
||||
}
|
||||
|
||||
return { response, error: null };
|
||||
} catch (error) {
|
||||
// redirect()는 NEXT_REDIRECT 에러를 throw하므로 다시 throw
|
||||
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
|
||||
throw error;
|
||||
}
|
||||
console.error(`[serverFetch] Network error: ${url}`, error);
|
||||
return {
|
||||
response: null,
|
||||
error: createErrorResponse(500, '네트워크 오류가 발생했습니다.', 'NETWORK_ERROR'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 응답 파싱 헬퍼
|
||||
*/
|
||||
export async function parseJsonResponse<T>(response: Response): Promise<T | null> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
console.error('[parseJsonResponse] JSON 파싱 실패');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 API 호출 헬퍼 (fetch + JSON 파싱)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { data, error } = await serverApiCall<UserResponse>(url, { method: 'GET' });
|
||||
* if (error) return error;
|
||||
* return data;
|
||||
* ```
|
||||
*/
|
||||
export async function serverApiCall<T>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<{ data: T | null; error: ApiErrorResponse | null }> {
|
||||
const { response, error } = await serverFetch(url, options);
|
||||
|
||||
if (error || !response) {
|
||||
return { data: null, error };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await parseJsonResponse<{ message?: string; code?: string }>(response);
|
||||
return {
|
||||
data: null,
|
||||
error: createErrorResponse(
|
||||
response.status,
|
||||
errorData?.message || '요청 처리 중 오류가 발생했습니다.',
|
||||
errorData?.code
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await parseJsonResponse<T>(response);
|
||||
return { data, error: null };
|
||||
}
|
||||
136
src/lib/api/refresh-token.ts
Normal file
136
src/lib/api/refresh-token.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 🔄 Refresh Token 공통 모듈
|
||||
*
|
||||
* 프록시(/api/proxy)와 serverFetch 양쪽에서 사용하는 공통 토큰 갱신 로직
|
||||
*
|
||||
* 문제: useEffect에서 여러 API 동시 호출 시 refresh_token 충돌
|
||||
* - 첫 번째 요청이 refresh_token 사용 → 성공 (토큰 폐기됨)
|
||||
* - 두 번째 요청이 같은 refresh_token 사용 → 실패 (이미 폐기됨)
|
||||
*
|
||||
* 해결: 5초간 refresh 결과 캐싱
|
||||
* - 동시 요청들이 같은 새 토큰을 공유
|
||||
* - 진행 중인 refresh Promise도 공유하여 중복 요청 방지
|
||||
* - 프록시와 serverFetch가 같은 캐시를 공유하여 더 효율적
|
||||
*/
|
||||
|
||||
export type RefreshResult = {
|
||||
success: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
// 캐시 상태 (모듈 레벨에서 공유)
|
||||
let refreshCache: {
|
||||
promise: Promise<RefreshResult> | null;
|
||||
timestamp: number;
|
||||
result: RefreshResult | null;
|
||||
} = {
|
||||
promise: null,
|
||||
timestamp: 0,
|
||||
result: null,
|
||||
};
|
||||
|
||||
const REFRESH_CACHE_TTL = 5000; // 5초
|
||||
|
||||
/**
|
||||
* 실제 토큰 갱신 수행 (내부 함수)
|
||||
*/
|
||||
async function doRefreshToken(refreshToken: string): Promise<RefreshResult> {
|
||||
try {
|
||||
const refreshUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`;
|
||||
console.log('🔄 [RefreshToken] Refresh request:', {
|
||||
url: refreshUrl,
|
||||
hasApiKey: !!process.env.API_KEY,
|
||||
refreshTokenLength: refreshToken?.length,
|
||||
});
|
||||
|
||||
const response = await fetch(refreshUrl, {
|
||||
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) {
|
||||
const errorBody = await response.text();
|
||||
console.warn('🔴 [RefreshToken] Token refresh failed:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody,
|
||||
});
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('✅ [RefreshToken] Token refreshed successfully');
|
||||
|
||||
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 시작
|
||||
*
|
||||
* @param refreshToken - 현재 refresh_token
|
||||
* @param caller - 호출자 식별 (로그용: 'PROXY' | 'serverFetch')
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
caller: string = 'unknown'
|
||||
): Promise<RefreshResult> {
|
||||
const now = Date.now();
|
||||
|
||||
// 1. 캐시된 결과가 유효하면 즉시 반환
|
||||
if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
|
||||
console.log(`🔵 [${caller}] Using cached refresh result (age: ${now - refreshCache.timestamp}ms)`);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화 (테스트용)
|
||||
*/
|
||||
export function clearRefreshCache(): void {
|
||||
refreshCache = {
|
||||
promise: null,
|
||||
timestamp: 0,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user