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:
byeongcheolryu
2025-12-30 17:00:18 +09:00
parent 0e5307f7a3
commit d38b1242d7
82 changed files with 7434 additions and 4775 deletions

View 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` | ✅ 완료 |

View 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 (정산관리)

View File

@@ -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/ - 레거시/완료된 문서
완료되거나 더 이상 활성화되지 않은 문서들. 참조용으로 보관.

View 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');
}
```

View File

@@ -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개

View 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은 충분히 짧아 토큰 갱신 지연 문제 없음
- 실패 시 다음 요청에서 새로 갱신 시도
- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화

View 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/` - 컴포넌트 파일

View File

@@ -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.

View File

@@ -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>
);
}

View 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;

View File

@@ -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}

View File

@@ -0,0 +1,5 @@
import { PartnerListClient } from '@/components/business/juil/partners';
export default function PartnersPage() {
return <PartnerListClient />;
}

View File

@@ -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>
<ApiErrorProvider>
<AuthenticatedLayout>{children}</AuthenticatedLayout>
</ApiErrorProvider>
</RootProvider>
);
}

View File

@@ -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 = [

View 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 }
);
}
}

View File

@@ -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,8 +194,28 @@ 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 대기
// 다시 refresh 시도 (다른 요청이 새 refresh_token을 발급받았을 수 있음)
const latestRefreshToken = request.cookies.get('refresh_token')?.value;
if (latestRefreshToken) {
const retryResult = await refreshAccessToken(latestRefreshToken, 'PROXY');
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);
}
}
// 여전히 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 },
@@ -247,6 +229,7 @@ async function proxyRequest(
return clearResponse;
}
}
}
// 6. 응답 데이터 읽기
console.log('🔵 [PROXY] Response status:', backendResponse.status);

View File

@@ -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 || '메모 삭제에 실패했습니다.',
};
}

View File

@@ -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}`,
};
}

View File

@@ -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) {

View File

@@ -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}`,
};
}

View File

@@ -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}`,
};
}

View File

@@ -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,8 +84,6 @@ export async function getDeposits(params?: {
};
error?: string;
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
@@ -99,19 +101,24 @@ export async function getDeposits(params?: {
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);
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('[DepositActions] GET deposits error:', response?.status);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: `API 오류: ${response?.status}`,
};
}
@@ -155,47 +162,24 @@ export async function getDeposits(params?: {
total: meta.total,
},
};
} catch (error) {
console.error('[DepositActions] getDeposits error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 입금 내역 삭제 =====
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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '입금 내역 삭제에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
// ===== 계정과목명 일괄 저장 =====
@@ -203,38 +187,26 @@ export async function updateDepositTypes(
ids: string[],
depositType: string
): Promise<{ success: boolean; error?: string }> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/bulk-update-type`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/bulk-update-type`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify({
ids: ids.map(id => parseInt(id, 10)),
deposit_type: depositType,
}),
});
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '계정과목명 저장에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
// ===== 입금 상세 조회 =====
@@ -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 (error) {
return { success: false, error: error.message };
}
);
if (!response.ok) {
console.error('[DepositActions] GET deposit error:', response.status);
return {
success: false,
error: `API 오류: ${response.status}`,
};
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: false, error: result.message || '입금 내역 조회에 실패했습니다.' };
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[DepositActions] getDepositById error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 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;
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);
console.log('[DepositActions] POST deposit request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits`;
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '입금 등록에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[DepositActions] PUT deposit request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/deposits/${id}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '입금 수정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
return { success: true, data: transformApiToFrontend(result.data) };
}
// ===== 거래처 목록 조회 =====
@@ -393,20 +297,15 @@ 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 (error) {
return { success: false, data: [], error: error.message };
}
);
if (!response.ok) {
return { success: false, data: [], error: `API 오류: ${response.status}` };
if (!response?.ok) {
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
@@ -424,8 +323,4 @@ export async function getVendors(): Promise<{
name: c.name,
})),
};
} catch (error) {
console.error('[DepositActions] getVendors error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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();

View File

@@ -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,8 +115,6 @@ export async function getPurchases(params?: {
pagination: PaginationMeta;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
@@ -147,19 +132,24 @@ export async function getPurchases(params?: {
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);
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('[PurchaseActions] GET purchases error:', response?.status);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: `API 오류: ${response?.status}`,
};
}
@@ -194,15 +184,6 @@ export async function getPurchases(params?: {
total: paginatedData.total,
},
};
} catch (error) {
console.error('[PurchaseActions] getPurchases error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 매입 상세 조회 =====
@@ -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 (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}`,
};
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: false, error: result.message || '매입 조회에 실패했습니다.' };
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[PurchaseActions] getPurchaseById error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[PurchaseActions] POST purchase request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases`;
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '매입 등록에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[PurchaseActions] PUT purchase request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/purchases/${id}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '매입 수정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
console.log('[PurchaseActions] DELETE purchase response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '매입 삭제에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
// ===== 매입 확정 =====
@@ -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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
console.log('[PurchaseActions] PUT confirm response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '매입 확정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
return { success: true, data: transformApiToFrontend(result.data) };
}
// ===== 은행 계좌 목록 조회 =====
@@ -425,20 +324,15 @@ 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 (error) {
return { success: false, data: [], error: error.message };
}
);
if (!response.ok) {
return { success: false, data: [], error: `API 오류: ${response.status}` };
if (!response?.ok) {
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
@@ -458,10 +352,6 @@ export async function getBankAccounts(): Promise<{
accountNumber: a.account_number,
})),
};
} catch (error) {
console.error('[PurchaseActions] getBankAccounts error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}
// ===== 거래처 목록 조회 =====
@@ -470,20 +360,15 @@ 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 (error) {
return { success: false, data: [], error: error.message };
}
);
if (!response.ok) {
return { success: false, data: [], error: `API 오류: ${response.status}` };
if (!response?.ok) {
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
@@ -501,8 +386,4 @@ export async function getVendors(): Promise<{
name: c.name,
})),
};
} catch (error) {
console.error('[PurchaseActions] getVendors error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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}`,
};
}

View File

@@ -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,8 +44,6 @@ export async function getSales(params?: {
pagination: PaginationMeta;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
@@ -76,19 +61,24 @@ export async function getSales(params?: {
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);
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('[SalesActions] GET sales error:', response?.status);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: `API 오류: ${response?.status}`,
};
}
@@ -124,15 +114,6 @@ export async function getSales(params?: {
total: paginatedData.total,
},
};
} catch (error) {
console.error('[SalesActions] getSales error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 매출 상세 조회 =====
@@ -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 (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}`,
};
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: false, error: result.message || '매출 조회에 실패했습니다.' };
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[SalesActions] getSaleById error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[SalesActions] POST sale request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales`;
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '매출 등록에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[SalesActions] PUT sale request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/${id}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '매출 수정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
console.log('[SalesActions] DELETE sale response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '매출 삭제에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
// ===== 매출 확정 =====
@@ -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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
console.log('[SalesActions] PUT confirm response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '매출 확정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
return { success: true, data: transformApiToFrontend(result.data) };
}
// ===== 매출 요약 통계 =====
@@ -371,8 +270,6 @@ export async function getSalesSummary(params?: {
};
error?: string;
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.startDate) searchParams.set('start_date', params.startDate);
@@ -381,27 +278,21 @@ export async function getSalesSummary(params?: {
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}`,
};
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 || '매출 요약 조회에 실패했습니다.',
};
return { success: false, error: result.message || '매출 요약 조회에 실패했습니다.' };
}
// API 응답 → 프론트엔드 형식 변환
@@ -418,11 +309,4 @@ export async function getSalesSummary(params?: {
draftCount: summary.draft_count || 0,
},
};
} catch (error) {
console.error('[SalesActions] getSalesSummary error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -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}`,
};
}

View File

@@ -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,8 +151,6 @@ 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();
if (params?.page) searchParams.set('page', String(params.page));
@@ -178,15 +161,15 @@ export async function getClients(params?: {
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}` };
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();
@@ -198,35 +181,18 @@ export async function getClients(params?: {
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: '서버 오류가 발생했습니다.' };
}
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);
if (error || !response?.ok) {
console.error('[VendorActions] GET client error:', error?.message || response?.status);
return null;
}
@@ -238,10 +204,6 @@ export async function getClientById(id: string): Promise<Vendor | null> {
}
return transformApiToFrontend(result.data);
} catch (error) {
console.error('[VendorActions] getClientById error:', error);
return null;
}
}
/**
@@ -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);
console.log('[VendorActions] POST client request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`;
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '거래처 등록에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[VendorActions] PUT client request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '거래처 수정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
console.log('[VendorActions] DELETE client response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '거래처 삭제에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
/**
* 거래처 활성/비활성 토글
*/
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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
console.log('[VendorActions] PATCH toggle response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '상태 변경에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
return { success: true, data: transformApiToFrontend(result.data) };
}

View File

@@ -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,8 +81,6 @@ export async function getWithdrawals(params?: {
};
error?: string;
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
@@ -97,19 +98,24 @@ export async function getWithdrawals(params?: {
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);
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('[WithdrawalActions] GET withdrawals error:', response?.status);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: `API 오류: ${response?.status}`,
};
}
@@ -142,47 +148,24 @@ export async function getWithdrawals(params?: {
total: meta.total,
},
};
} catch (error) {
console.error('[WithdrawalActions] getWithdrawals error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 출금 내역 삭제 =====
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,
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '출금 내역 삭제에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
// ===== 계정과목명 일괄 저장 =====
@@ -190,38 +173,26 @@ export async function updateWithdrawalTypes(
ids: string[],
withdrawalType: string
): Promise<{ success: boolean; error?: string }> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/bulk-update-type`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/bulk-update-type`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify({
ids: ids.map(id => parseInt(id, 10)),
withdrawal_type: withdrawalType,
}),
});
if (error) {
return { success: false, error: error.message };
}
);
const result = await response.json();
const result = await response?.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '계정과목명 저장에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
}
// ===== 출금 상세 조회 =====
@@ -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 (error) {
return { success: false, error: error.message };
}
);
if (!response.ok) {
console.error('[WithdrawalActions] GET withdrawal error:', response.status);
return {
success: false,
error: `API 오류: ${response.status}`,
};
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: false, error: result.message || '출금 내역 조회에 실패했습니다.' };
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[WithdrawalActions] getWithdrawalById error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 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;
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);
console.log('[WithdrawalActions] POST withdrawal request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals`;
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '출금 등록에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
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);
console.log('[WithdrawalActions] PUT withdrawal request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
});
const result = await response.json();
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 || '출금 수정에 실패했습니다.',
};
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: '서버 오류가 발생했습니다.',
};
}
return { success: true, data: transformApiToFrontend(result.data) };
}
// ===== 거래처 목록 조회 =====
@@ -379,20 +283,15 @@ 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 (error) {
return { success: false, data: [], error: error.message };
}
);
if (!response.ok) {
return { success: false, data: [], error: `API 오류: ${response.status}` };
if (!response?.ok) {
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
@@ -410,8 +309,4 @@ export async function getVendors(): Promise<{
name: c.name,
})),
};
} catch (error) {
console.error('[WithdrawalActions] getVendors error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({}),
});
// serverFetch handles 401 with redirect
if (error || !response) {
return {
success: false,
error: error?.message || '미열람 처리에 실패했습니다.',
};
}
);
const result = await response.json();

View File

@@ -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"
/>
)}
</>
);
}

View File

@@ -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) {

View File

@@ -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}`,
{
const { response, error } = await serverFetch(url, {
method: 'GET',
headers,
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}`,
{
const { response, error } = await serverFetch(url, {
method: 'GET',
headers,
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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
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}`,
{
const { response, error } = await serverFetch(url, {
method: 'PUT',
headers,
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}`,
{
const { response, error } = await serverFetch(url, {
method: 'DELETE',
headers,
}
);
});
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 {

View File

@@ -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 };

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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: '일괄 삭제에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,3 @@
export { default as PartnerListClient } from './PartnerListClient';
export * from './types';
export * from './actions';

View 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;
}

View File

@@ -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 };

View File

@@ -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,22 +160,16 @@ 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 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);
if (error || !response) {
console.error('[AttendanceActions] GET employees error:', error?.message);
return [];
}
@@ -207,10 +189,6 @@ export async function getEmployeesForAttendance(): Promise<EmployeeOption[]> {
position: emp.position_key || emp.tenant_user_profile?.position?.name || '',
rank: emp.tenant_user_profile?.rank || '',
}));
} catch (error) {
console.error('[AttendanceActions] getEmployeesForAttendance error:', error);
return [];
}
}
// ============================================
@@ -232,8 +210,6 @@ 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();
if (params?.page) searchParams.set('page', String(params.page));
@@ -249,16 +225,12 @@ export async function getAttendances(params?: {
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);
if (error || !response) {
console.error('[AttendanceActions] GET list error:', error?.message);
return { data: [], total: 0, lastPage: 1 };
}
@@ -274,30 +246,16 @@ export async function getAttendances(params?: {
total: result.data.total,
lastPage: result.data.last_page,
};
} catch (error) {
console.error('[AttendanceActions] getAttendances error:', error);
return { data: [], total: 0, lastPage: 1 };
}
}
/**
* 근태 상세 조회
*/
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);
if (error || !response) {
console.error('[AttendanceActions] GET attendance error:', error?.message);
return null;
}
@@ -308,10 +266,6 @@ export async function getAttendanceById(id: string): Promise<AttendanceRecord |
}
return transformApiToFrontend(result.data);
} catch (error) {
console.error('[AttendanceActions] getAttendanceById error:', error);
return null;
}
}
/**
@@ -320,25 +274,22 @@ 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);
console.log('[AttendanceActions] POST attendance request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances`,
{
const { response, error } = await serverFetch(`${API_URL}/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);
if (!response.ok || !result.success) {
if (!result.success) {
return {
success: false,
error: result.message || '근태 등록에 실패했습니다.',
@@ -349,13 +300,6 @@ export async function createAttendance(
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[AttendanceActions] createAttendance error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
@@ -365,25 +309,22 @@ export async function updateAttendance(
id: string,
data: AttendanceFormData
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
console.log('[AttendanceActions] PATCH attendance request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/${id}`,
{
const { response, error } = await serverFetch(`${API_URL}/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);
if (!response.ok || !result.success) {
if (!result.success) {
return {
success: false,
error: result.message || '근태 수정에 실패했습니다.',
@@ -394,34 +335,22 @@ export async function updateAttendance(
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[AttendanceActions] updateAttendance error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 근태 삭제
*/
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);
if (!response.ok || !result.success) {
if (!result.success) {
return {
success: false,
error: result.message || '근태 삭제에 실패했습니다.',
@@ -429,35 +358,25 @@ export async function deleteAttendance(id: string): Promise<{ success: boolean;
}
return { success: true };
} catch (error) {
console.error('[AttendanceActions] deleteAttendance error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 근태 일괄 삭제
*/
export async function deleteAttendances(ids: string[]): Promise<{ success: boolean; error?: string }> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/bulk-delete`,
{
const { response, error } = await serverFetch(`${API_URL}/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);
if (!response.ok || !result.success) {
if (!result.success) {
return {
success: false,
error: result.message || '근태 일괄 삭제에 실패했습니다.',
@@ -465,13 +384,6 @@ export async function deleteAttendances(ids: string[]): Promise<{ success: boole
}
return { success: true };
} catch (error) {
console.error('[AttendanceActions] deleteAttendances error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
@@ -482,24 +394,18 @@ export async function getMonthlyStats(params: {
month: number;
user_id?: string;
}): Promise<AttendanceStats | null> {
try {
const headers = await getApiHeaders();
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);
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);
if (error || !response) {
console.error('[AttendanceActions] GET monthly-stats error:', error?.message);
return null;
}
@@ -517,8 +423,4 @@ export async function getMonthlyStats(params: {
totalWorkMinutes: result.data.total_work_minutes,
totalOvertimeMinutes: result.data.total_overtime_minutes,
};
} catch (error) {
console.error('[AttendanceActions] getMonthlyStats error:', error);
return null;
}
}

View File

@@ -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,8 +134,6 @@ 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();
if (params?.search) searchParams.set('search', params.search);
@@ -157,15 +142,15 @@ export async function getCards(params?: {
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 { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, error: error?.message || '카드 목록을 불러오는데 실패했습니다.' };
}
const result: CardListResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '카드 목록을 불러오는데 실패했습니다.' };
}
@@ -178,27 +163,21 @@ export async function getCards(params?: {
lastPage: result.data.last_page,
},
};
} catch (error) {
console.error('[getCards] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 카드 상세 조회
*/
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' });
if (error || !response) {
return { success: false, error: error?.message || '카드 정보를 불러오는데 실패했습니다.' };
}
const result: CardResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '카드 정보를 불러오는데 실패했습니다.' };
}
@@ -206,29 +185,25 @@ export async function getCard(id: string): Promise<{ success: boolean; data?: Ca
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[getCard] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 카드 등록
*/
export async function createCard(data: CardFormData): Promise<{ success: boolean; data?: Card; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data);
const response = await fetch(`${API_URL}/v1/cards`, {
const { response, error } = await serverFetch(`${API_URL}/v1/cards`, {
method: 'POST',
headers,
body: JSON.stringify(apiData),
});
if (error || !response) {
return { success: false, error: error?.message || '카드 등록에 실패했습니다.' };
}
const result: CardResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '카드 등록에 실패했습니다.' };
}
@@ -236,29 +211,25 @@ export async function createCard(data: CardFormData): Promise<{ success: boolean
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[createCard] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 카드 수정
*/
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 response = await fetch(`${API_URL}/v1/cards/${id}`, {
const { response, error } = await serverFetch(`${API_URL}/v1/cards/${id}`, {
method: 'PUT',
headers,
body: JSON.stringify(apiData),
});
if (error || !response) {
return { success: false, error: error?.message || '카드 수정에 실패했습니다.' };
}
const result: CardResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '카드 수정에 실패했습니다.' };
}
@@ -266,34 +237,25 @@ export async function updateCard(id: string, data: CardFormData): Promise<{ succ
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[updateCard] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 카드 삭제
*/
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' });
if (error || !response) {
return { success: false, error: error?.message || '카드 삭제에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '카드 삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
console.error('[deleteCard] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
@@ -319,16 +281,15 @@ 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' });
if (error || !response) {
return { success: false, error: error?.message || '상태 변경에 실패했습니다.' };
}
const result: CardResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
}
@@ -336,10 +297,6 @@ export async function toggleCardStatus(id: string): Promise<{ success: boolean;
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[toggleCardStatus] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
@@ -347,17 +304,15 @@ 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' });
if (error || !response) {
return { success: false, error: error?.message || '직원 목록을 불러오는데 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '직원 목록을 불러오는데 실패했습니다.' };
}
@@ -375,8 +330,4 @@ export async function getActiveEmployees(): Promise<{ success: boolean; data?: A
}));
return { success: true, data: employees };
} catch (error) {
console.error('[getActiveEmployees] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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,8 +129,6 @@ 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();
if (params?.withUsers) {
@@ -150,12 +136,13 @@ export async function getDepartmentTree(params?: {
}
const queryString = queryParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/tree${queryString ? `?${queryString}` : ''}`;
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' });
if (error || !response) {
return { success: false, error: error?.message || '부서 트리 조회에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment[]> = await response.json();
@@ -171,13 +158,6 @@ export async function getDepartmentTree(params?: {
success: false,
error: result.message || '부서 트리 조회에 실패했습니다.',
};
} catch (error) {
console.error('[getDepartmentTree] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '부서 트리 조회에 실패했습니다.',
};
}
}
/**
@@ -187,12 +167,11 @@ 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' });
if (error || !response) {
return { success: false, error: error?.message || '부서 조회에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment> = await response.json();
@@ -207,13 +186,6 @@ export async function getDepartmentById(
success: false,
error: result.message || '부서 조회에 실패했습니다.',
};
} catch (error) {
console.error('[getDepartmentById] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '부서 조회에 실패했습니다.',
};
}
}
/**
@@ -223,11 +195,8 @@ 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`, {
const { response, error } = await serverFetch(`${API_URL}/v1/departments`, {
method: 'POST',
headers,
body: JSON.stringify({
parent_id: data.parentId,
code: data.code,
@@ -238,6 +207,10 @@ export async function createDepartment(
}),
});
if (error || !response) {
return { success: false, error: error?.message || '부서 생성에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment> = await response.json();
if (result.success && result.data) {
@@ -251,13 +224,6 @@ export async function createDepartment(
success: false,
error: result.message || '부서 생성에 실패했습니다.',
};
} catch (error) {
console.error('[createDepartment] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '부서 생성에 실패했습니다.',
};
}
}
/**
@@ -268,11 +234,8 @@ 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}`, {
const { response, error } = await serverFetch(`${API_URL}/v1/departments/${id}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
parent_id: data.parentId === null ? 0 : data.parentId, // null이면 0(최상위)으로 전환
code: data.code,
@@ -283,6 +246,10 @@ export async function updateDepartment(
}),
});
if (error || !response) {
return { success: false, error: error?.message || '부서 수정에 실패했습니다.' };
}
const result: ApiResponse<ApiDepartment> = await response.json();
if (result.success && result.data) {
@@ -296,13 +263,6 @@ export async function updateDepartment(
success: false,
error: result.message || '부서 수정에 실패했습니다.',
};
} catch (error) {
console.error('[updateDepartment] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '부서 수정에 실패했습니다.',
};
}
}
/**
@@ -312,12 +272,11 @@ 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' });
if (error || !response) {
return { success: false, error: error?.message || '부서 삭제에 실패했습니다.' };
}
const result: ApiResponse<{ id: number; deleted_at: string }> = await response.json();
@@ -329,13 +288,6 @@ export async function deleteDepartment(
success: false,
error: result.message || '부서 삭제에 실패했습니다.',
};
} catch (error) {
console.error('[deleteDepartment] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '부서 삭제에 실패했습니다.',
};
}
}
/**

View File

@@ -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,9 +293,10 @@ export function EmployeeForm({
<div className="flex items-start justify-between mb-6">
<PageHeader
title={title}
description={mode === 'create' ? '새로운 사원 정보를 입력합니다' : '사원 정보를 수정합니다'}
description={description}
icon={Users}
/>
{!isViewMode && (
<Button
type="button"
variant="outline"
@@ -216,6 +305,7 @@ export function EmployeeForm({
<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,11 +419,22 @@ 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">
<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"
@@ -340,10 +452,13 @@ export function EmployeeForm({
}
}}
/>
)}
</div>
{!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">
{!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,6 +614,7 @@ export function EmployeeForm({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>/</Label>
{!isViewMode && (
<Button
type="button"
variant="outline"
@@ -498,11 +624,12 @@ export function EmployeeForm({
<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,13 +640,16 @@ 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}
/>
{!isViewMode && (
<Button
type="button"
variant="ghost"
@@ -528,6 +658,7 @@ export function EmployeeForm({
>
<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>
{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>

View File

@@ -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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
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}`,
{
const { response, error } = await serverFetch(url, {
method: 'PATCH',
headers,
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 response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/bulk-delete`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/bulk-delete`;
const { response, error } = await serverFetch(url, {
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}` };
}

View File

@@ -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,8 +171,6 @@ export async function getSalaries(params?: {
pagination?: { total: number; currentPage: number; lastPage: number };
error?: string
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
@@ -199,15 +184,15 @@ export async function getSalaries(params?: {
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 { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, error: error?.message || '급여 목록을 불러오는데 실패했습니다.' };
}
const result: SalaryListResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '급여 목록을 불러오는데 실패했습니다.' };
}
@@ -220,10 +205,6 @@ export async function getSalaries(params?: {
lastPage: result.data.last_page,
},
};
} catch (error) {
console.error('[getSalaries] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
@@ -234,17 +215,15 @@ 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' });
if (error || !response) {
return { success: false, error: error?.message || '급여 정보를 불러오는데 실패했습니다.' };
}
const result: SalaryResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '급여 정보를 불러오는데 실패했습니다.' };
}
@@ -252,10 +231,6 @@ export async function getSalary(id: string): Promise<{
success: true,
data: transformApiToDetail(result.data),
};
} catch (error) {
console.error('[getSalary] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
@@ -265,17 +240,18 @@ 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`, {
const { response, error } = await serverFetch(`${API_URL}/v1/salaries/${id}/status`, {
method: 'PATCH',
headers,
body: JSON.stringify({ status }),
});
if (error || !response) {
return { success: false, error: error?.message || '상태 변경에 실패했습니다.' };
}
const result: SalaryResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
}
@@ -283,10 +259,6 @@ export async function updateSalaryStatus(
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[updateSalaryStatus] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
@@ -296,20 +268,21 @@ 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`, {
const { response, error } = await serverFetch(`${API_URL}/v1/salaries/bulk-update-status`, {
method: 'POST',
headers,
body: JSON.stringify({
ids: ids.map(id => parseInt(id, 10)),
status
}),
});
if (error || !response) {
return { success: false, error: error?.message || '일괄 상태 변경에 실패했습니다.' };
}
const result: BulkUpdateResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '일괄 상태 변경에 실패했습니다.' };
}
@@ -317,10 +290,6 @@ export async function bulkUpdateSalaryStatus(
success: true,
updatedCount: result.data.updated_count,
};
} catch (error) {
console.error('[bulkUpdateSalaryStatus] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
@@ -346,8 +315,6 @@ export async function getSalaryStatistics(params?: {
};
error?: string
}> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (params?.year) searchParams.set('year', String(params.year));
@@ -356,15 +323,15 @@ export async function getSalaryStatistics(params?: {
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 { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
return { success: false, error: error?.message || '통계 정보를 불러오는데 실패했습니다.' };
}
const result: StatisticsResponse = await response.json();
if (!response.ok || !result.success) {
if (!result.success) {
return { success: false, error: result.message || '통계 정보를 불러오는데 실패했습니다.' };
}
@@ -382,8 +349,4 @@ export async function getSalaryStatistics(params?: {
completedCount: result.data.completed_count,
},
};
} catch (error) {
console.error('[getSalaryStatistics] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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();

View File

@@ -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++;

View File

@@ -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) }
);
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] POST process 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('[ReceivingActions] processReceiving error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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" />

View File

@@ -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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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: '서버 오류가 발생했습니다.',
};
}

View File

@@ -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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}`,
{
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('[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`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes`;
const { response, error } = await serverFetch(url, {
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('[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}`,
{
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}`;
const { response, error } = await serverFetch(url, {
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('[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}`,
{
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();
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`,
{
const { response, error } = await serverFetch(url, {
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 = 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`,
{
const { response, error } = await serverFetch(url, {
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('[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`,
{
const { response, error } = await serverFetch(url, {
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('[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`,
{
const { response, error } = await serverFetch(url, {
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('[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`,
{
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 = 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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
});
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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
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`,
{
const { response, error } = await serverFetch(url, {
method: 'POST',
headers,
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,
};
}

View File

@@ -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 || '반려에 실패했습니다.',

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_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 = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_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}`,
};
}

View File

@@ -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) {

View File

@@ -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}`,
};
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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}`,
};
}

View 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
View 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;
}

View 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 };
}

View 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