chore(WEB): 다수 컴포넌트 개선 및 CEO 대시보드 추가
- CEO 대시보드 컴포넌트 추가 - AuthenticatedLayout 개선 - 각 모듈 actions.ts 에러 핸들링 개선 - API fetch-wrapper, refresh-token 로직 개선 - ReceivablesStatus 컴포넌트 업데이트 - globals.css 스타일 업데이트 - 기타 다수 컴포넌트 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
240
claudedocs/[ANALYSIS-2026-01-07] permission-system-status.md
Normal file
240
claudedocs/[ANALYSIS-2026-01-07] permission-system-status.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 권한 관리 시스템 현황 분석
|
||||
|
||||
> 작성일: 2026-01-07
|
||||
> 목적: SAM 프로젝트 권한 시스템 현황 파악 및 향후 구현 계획 정리
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 상태 요약
|
||||
|
||||
| 구분 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| 권한 설정 UI | ✅ 완성 | `/settings/permissions/[id]`에서 역할별 권한 설정 가능 |
|
||||
| 백엔드 권한 API | ✅ 존재 | 권한 매트릭스 조회/설정 API 구현됨 |
|
||||
| 백엔드 API 권한 체크 | ⚠️ 구조만 있음 | 미들웨어 존재하나 라우트에 미적용 |
|
||||
| 프론트 권한 체크 | ❌ 미구현 | 권한 매트릭스 조회 및 UI 제어 로직 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 권한 타입 (7가지)
|
||||
|
||||
| 권한 | 영문 | 적용 대상 |
|
||||
|------|------|----------|
|
||||
| 조회 | `view` | 페이지 접근 |
|
||||
| 생성 | `create` | 등록/추가 버튼 |
|
||||
| 수정 | `update` | 수정 버튼 |
|
||||
| 삭제 | `delete` | 삭제 버튼 |
|
||||
| 승인 | `approve` | 승인/반려 버튼 |
|
||||
| 내보내기 | `export` | Excel 다운로드 등 |
|
||||
| 관리 | `manage` | 관리자 전용 기능 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 API 구조
|
||||
|
||||
### 3.1 로그인 API
|
||||
|
||||
**엔드포인트**: `POST /api/v1/login`
|
||||
|
||||
**응답 구조**:
|
||||
```json
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"user": { "id": 1, "name": "..." },
|
||||
"menus": [...],
|
||||
"roles": [...]
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **주의**: 로그인 응답에 **권한 매트릭스(permissions)는 포함되지 않음**
|
||||
|
||||
### 3.2 권한 매트릭스 조회 API
|
||||
|
||||
**사용자별 권한 조회**:
|
||||
```
|
||||
GET /api/v1/permissions/users/{userId}/menu-matrix
|
||||
```
|
||||
|
||||
**응답 구조**:
|
||||
```json
|
||||
{
|
||||
"permission_types": ["view", "create", "update", "delete", "approve", "export", "manage"],
|
||||
"permissions": {
|
||||
"1": { "view": true, "create": true, "update": false, ... },
|
||||
"2": { "view": true, "create": false, ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 기타 권한 API
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/v1/permissions/departments/{dept_id}/menu-matrix` | 부서별 권한 매트릭스 |
|
||||
| `GET /api/v1/permissions/roles/{role_id}/menu-matrix` | 역할별 권한 매트릭스 |
|
||||
| `GET /api/v1/roles/{id}/permissions/matrix` | 역할 권한 매트릭스 (설정 UI용) |
|
||||
| `POST /api/v1/roles/{id}/permissions/toggle` | 개별 권한 토글 |
|
||||
| `POST /api/v1/roles/{id}/permissions/allow-all` | 전체 허용 |
|
||||
| `POST /api/v1/roles/{id}/permissions/deny-all` | 전체 거부 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 권한 체크 미들웨어
|
||||
|
||||
### 4.1 CheckPermission.php
|
||||
|
||||
```php
|
||||
// 권한 체크 로직
|
||||
if (! AccessService::allows($user, $perm, $tenantId, 'api')) {
|
||||
return response()->json(['message' => '권한이 없습니다.'], 403);
|
||||
}
|
||||
|
||||
// 단, perm 미지정 라우트는 통과 (현재 정책)
|
||||
if (! $perm && ! $permsAny) {
|
||||
return $next($request); // ← 현재 모든 API가 여기로 통과
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 PermMapper.php
|
||||
|
||||
HTTP 메서드에 따라 액션 자동 매핑:
|
||||
|
||||
| HTTP 메서드 | 권한 액션 |
|
||||
|------------|----------|
|
||||
| GET, HEAD | view |
|
||||
| POST | create |
|
||||
| PUT, PATCH | update |
|
||||
| DELETE | delete |
|
||||
|
||||
**권한 형식**: `menu:{menuId}.{action}` (예: `menu:1.view`)
|
||||
|
||||
### 4.3 현재 상태
|
||||
|
||||
- 미들웨어 구조는 갖춰져 있음
|
||||
- **라우트에 `menu_id` 설정이 안 되어 있어 실제 권한 체크 미동작**
|
||||
- 모든 API가 권한 체크 없이 통과
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 현재 상태
|
||||
|
||||
### 5.1 구현된 것
|
||||
|
||||
- 로그인 시 `menus`, `roles` 데이터 저장 (localStorage)
|
||||
- 사이드바 메뉴 표시 (백엔드에서 필터링된 메뉴)
|
||||
- 메뉴 폴링 (30초 주기)
|
||||
|
||||
### 5.2 미구현 사항
|
||||
|
||||
- 권한 매트릭스 API 호출
|
||||
- 권한 데이터 저장
|
||||
- `usePermission` 훅
|
||||
- 페이지/버튼별 권한 체크
|
||||
|
||||
---
|
||||
|
||||
## 6. 향후 구현 계획
|
||||
|
||||
### 6.1 프론트엔드 (1단계 - UI 제어)
|
||||
|
||||
```
|
||||
로그인 성공
|
||||
↓
|
||||
/api/v1/permissions/users/{userId}/menu-matrix 호출
|
||||
↓
|
||||
권한 매트릭스 저장 (Zustand/localStorage)
|
||||
↓
|
||||
usePermission 훅으로 권한 체크
|
||||
↓
|
||||
버튼/기능 숨김/비활성화
|
||||
```
|
||||
|
||||
**usePermission 훅 예시**:
|
||||
```typescript
|
||||
// 사용법
|
||||
const { canView, canCreate, canUpdate, canDelete } = usePermission('판매관리');
|
||||
|
||||
// 적용
|
||||
{canCreate && <Button>등록</Button>}
|
||||
{canDelete && <Button>삭제</Button>}
|
||||
```
|
||||
|
||||
**환경 변수 플래그**:
|
||||
```env
|
||||
NEXT_PUBLIC_ENABLE_AUTHORIZATION=false # 개발 중에는 비활성화
|
||||
```
|
||||
|
||||
### 6.2 백엔드 (2단계 - API 보안)
|
||||
|
||||
라우트에 `menu_id` 설정하여 API 레벨 권한 체크 활성화:
|
||||
|
||||
```php
|
||||
// 예시: routes/api.php
|
||||
Route::get('/orders', [OrderController::class, 'index'])
|
||||
->defaults('menu_id', 5); // 판매관리 메뉴 ID
|
||||
|
||||
Route::post('/orders', [OrderController::class, 'store'])
|
||||
->defaults('menu_id', 5); // POST → create 권한 자동 체크
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 보안 고려사항
|
||||
|
||||
### 7.1 현재 취약점
|
||||
|
||||
- 프론트에서만 UI 숨기면 개발자 도구로 우회 가능
|
||||
- 직접 API 호출 시 권한 없이도 작업 가능
|
||||
|
||||
### 7.2 권장 구조 (이중 보안)
|
||||
|
||||
```
|
||||
프론트엔드: UI 컨트롤 (UX 향상)
|
||||
↓
|
||||
백엔드: API 권한 체크 (실제 보안)
|
||||
↓
|
||||
권한 없으면 403 반환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 파일 경로
|
||||
|
||||
### 프론트엔드 (sam-react-prod)
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `src/app/[locale]/(protected)/settings/permissions/` | 권한 설정 페이지 |
|
||||
| `src/components/settings/PermissionManagement/` | 권한 관리 컴포넌트 |
|
||||
| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 표시 레이아웃 |
|
||||
| `src/middleware.ts` | 인증 체크 (권한 체크 없음) |
|
||||
|
||||
### 백엔드 (sam-api)
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/PermissionController.php` | 권한 매트릭스 API |
|
||||
| `app/Http/Controllers/Api/V1/RolePermissionController.php` | 역할 권한 API |
|
||||
| `app/Http/Middleware/CheckPermission.php` | 권한 체크 미들웨어 |
|
||||
| `app/Http/Middleware/PermMapper.php` | HTTP → 액션 매핑 |
|
||||
| `app/Services/Authz/AccessService.php` | 권한 판정 서비스 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 결론
|
||||
|
||||
**현재**: 권한 설정은 가능하지만, 프론트/백엔드 모두 권한 체크 미적용
|
||||
|
||||
**1단계 (프론트)**:
|
||||
- 로그인 후 권한 매트릭스 API 호출
|
||||
- usePermission 훅으로 UI 제어
|
||||
- 환경 변수로 개발 중 비활성화
|
||||
|
||||
**2단계 (백엔드)**:
|
||||
- 라우트에 menu_id 설정
|
||||
- API 레벨 권한 체크 활성화
|
||||
|
||||
---
|
||||
|
||||
*이 문서는 권한 시스템 구현 시 참고용으로 작성되었습니다.*
|
||||
435
claudedocs/[IMPL-2026-01-07] ceo-dashboard-checklist.md
Normal file
435
claudedocs/[IMPL-2026-01-07] ceo-dashboard-checklist.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# [IMPL-2026-01-07] 대표님 전용 대시보드 구현
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 작업명 | 대표님 전용 대시보드 (CEO Dashboard) |
|
||||
| 기준 페이지 | `/reports/comprehensive-analysis` (종합분석) |
|
||||
| 대상 페이지 | `/dashboard` (대시보드) |
|
||||
| 기존 대시보드 처리 | 백업 후 새 대시보드로 교체 |
|
||||
| 공통 컴포넌트 활용 | `ScheduleCalendar` (달력) |
|
||||
|
||||
---
|
||||
|
||||
## 작업 범위
|
||||
|
||||
### Phase 1: 본 화면 구현 (현재 작업) ✅ 완료
|
||||
- [x] 스크린샷 분석 및 계획서 작성
|
||||
- [x] 기존 Dashboard 컴포넌트 백업
|
||||
- [x] CEO Dashboard 컴포넌트 생성
|
||||
- [x] 각 섹션별 컴포넌트 구현 (11개 섹션)
|
||||
|
||||
### Phase 2: 팝업/상세 화면 구현 (추후 작업)
|
||||
- [ ] 항목 설정 팝업
|
||||
- [ ] 일일 일보 정보 팝업
|
||||
- [ ] 해당월 예상 지출 상세 팝업
|
||||
- [ ] 납부세액 내역 상세 팝업
|
||||
- [ ] 일정 상세 팝업
|
||||
- [ ] 기타 상세 팝업들
|
||||
|
||||
---
|
||||
|
||||
## 페이지 구조 (스크린샷 기준)
|
||||
|
||||
### 섹션 1: 대시보드 헤더 (Page 31 상단)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LOGO 대시보드 - 전체 현황을 조회합니다. [항목 설정] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 항목 설정 버튼 | 우측 상단 | 대시보드 항목 설정 팝업 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 2: 오늘의 이슈 (Page 31)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 오늘의 이슈 │
|
||||
├──────────┬──────────┬──────────┬──────────────────────┤
|
||||
│ 수주 │ 채권 추심 │ 반전 재고 │ 제규 신고 │
|
||||
│ 3건 │ 3건 │ 3건 │ 부가세 신고 D-15 │
|
||||
├──────────┼──────────┼──────────┼──────────────────────┤
|
||||
│ 신규업체 │ 연차 │ 발주 │ 결재 요청 │
|
||||
│ 등록 3건│ 3건 │ 3건 │ 3건 │
|
||||
└──────────┴──────────┴──────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 수주 | 수주 건수 | 수주 관리 화면 이동 |
|
||||
| 채권 추심 | 채권 추심 건수 | 채권 추심 관리 화면 이동 |
|
||||
| 반전 재고 | 빨간색 강조 (위험) | 재고 관리 화면 이동 |
|
||||
| 제규 신고 | 부가세 신고 D-day | 세무 관리 화면 이동 |
|
||||
| 신규 업체 등록 | 신규 업체 건수 | 업체 관리 화면 이동 |
|
||||
| 연차 | 연차 신청 건수 | 연차 관리 화면 이동 |
|
||||
| 발주 | 발주 건수 | 발주 관리 화면 이동 |
|
||||
| 결재 요청 | 결재 대기 건수 | 결재 관리 화면 이동 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 3: 일일 일보 (Page 31)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 일일 일보 2026년 1월 5일 월요일 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 입금/자산 │ 전월 매출 │ (지표3) │ (지표4) │
|
||||
│ 30.5억원 │ $11,123,000 │ 10.2억원 │ 3.5억원 │
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ⚠️ 최근 7일 평균 대비 3배 이상으로 입금이 발생했습니다. │
|
||||
│ ⚠️ 102만원이 감지됐습니다... (이상거래 감지) │
|
||||
│ ℹ️ 현금성 자산이 300건전환입니다. 월 운영비와 비용보다... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 일일 일보 영역 전체 | 오늘 날짜 기준 일보 | 일일 일보 정보 팝업 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 4: 당월 예상 지출 내역 (Page 32)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 당월 예상 지출 내역 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 미청산가지급금│ 이달 예상 │ 전달 대비 │ 차이 │
|
||||
│ 30.5억원 │30,123,000원 │30,123,000원 │ 3.5억원 │
|
||||
│ 전달14%,+5% │ │ │ │
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ⚠️ 이번 달 예상 지출이 전달 해당 15% 증가했습니다... │
|
||||
│ ⚠️ 이번 달 예상 지출이 예상 12% 초과했습니다... │
|
||||
│ ✅ 이번 달 예상 지출이 전달 대비 8% 감소했습니다... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 가지급금 | 미청산 가지급금 | 가지급금 관리 화면 이동 |
|
||||
| 미청산 가지급금 | 대상 금액 | 미청산 가지급금 상세 화면 이동 |
|
||||
| 해당월 예상 지출 | 지출 상세 | 해당월 예상 지출 상세 팝업 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 5: 카드/가지급금 관리 (Page 32)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 카드/가지급금 관리 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 해당달 대상 │ 가지급금 │ 미정산 │ 총잔액 │
|
||||
│30,123,000원 │ 3.5억원 │3,123,000원 │3,123,000원│
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ⚠️ 법인카드 사용 총 85만원이 가지급금으로 전환됐습니다... │
|
||||
│ ⚠️ 전 가지급금 1,520만원은 4.6%, 연 약 70만원의 인정이자...│
|
||||
│ ⚠️ 상품권/귀금속 등 현대비 불인정 항목 매입 건이 있습니다 │
|
||||
│ ℹ️ 주말 카드 사용 총 100만원 중 결과 지의... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 법인카드 예상 가능 영역 | 카드 사용 현황 | 법인카드 관리 화면 이동 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 6: 접대비 현황 (Page 32~33)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 접대비 현황 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 접대비 한도 │ 접대비 사용액 │ 한도 잔액 │ 기타 │
|
||||
│ 305.3억원 │40,123,000원 │30,123,000원 │10,000,000원│
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ✅ 접대비 사용 총 2,400만원 중 / 한도 4,000만원 (60%)... │
|
||||
│ ⚠️ 접대비 85% 도달. 연내 한도 600만원 잔액입니다... │
|
||||
│ ❌ 접대비 한도 초과 320만원 발생. 손금불산입되어... │
|
||||
│ ℹ️ 접대비 사용 총 3건(45만원)이 거래처 한도 누락... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 접대비 영역 | 접대비 현황 | 해당월 예상 지출 상세 팝업 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 7: 복리후생비 현황 (Page 33)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 복리후생비 현황 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 총 복리후생비│누적 사용 │ 잠정 사용액 │ 잠정 한도 │
|
||||
│30,123,000원 │10,123,000원 │ 5,123,000원 │5,123,000원│
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ✅ 1인당 월 복리후생비 18만원. 업계 평균 내 정상 운영... │
|
||||
│ ⚠️ 식대가 월 25만원으로 비과세 한도 초과... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 섹션 8: 미수금 현황 (Page 33)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 미수금 현황 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 누계 미수금 │ 30일 초과 │ 60일 초과 │ 90일 초과 │
|
||||
│30,123,000원 │10,123,000원 │ 3,123,000원 │2,123,000원│
|
||||
│매출:6,012만 │매출:6,012만 │매출:6,012만 │매출:6,012만│
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ❌ 90일 이상 장기 미수금 3건(2,500만원) 발생. 회수조치... │
|
||||
│ ⚠️ (주)대한전자 미수금 4,500만원으로 전체의 35%... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 미수금 현황 목록 | 미수금 상세 | 미수금 상세 화면으로 이동 (1,2차 표시) |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 9: 채권추심 현황 (Page 34)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 채권추심 현황 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 총 채권 │ 추심 진행 │ 이달(?) │ 미회수(?) │
|
||||
│ 3.5억원 │30,123,000원 │ 3,123,000원 │ 2.8억원 │
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ℹ️ (주)대한전자 건 지급명령 신청 완료. 법원 결정까지... │
|
||||
│ ⚠️ (주)삼성테크 건 회수 불가 판정. 대손 처리 검토... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 채권추심 현황 확록 | 채권 추심 목록 | 미상대금 수심관리 화면으로 이동 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 10: 부가세 현황 (Page 34~35)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 🔴 부가세 현황 │
|
||||
├──────────────┬──────────────┬──────────────┬───────────┤
|
||||
│ 예상 납부세액 │ 예상 납부세액 │ 금액 │ 건수 │
|
||||
│ 30.5억원 │ 20.5억원 │ 1.1억원 │ 3건 │
|
||||
└──────────────┴──────────────┴──────────────┴───────────┘
|
||||
│ ⚠️ 2026년 1기 예정신고 기한, 예상 환급세액은 5,200... │
|
||||
│ ⚠️ 2026년 1기 예정신고 기한, 예상 납부세액은 118,100... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 부가세 현황 확록 | 납부세액 내역 | 해당 납부세액 내역 상세 팝업 표시 |
|
||||
|
||||
---
|
||||
|
||||
### 섹션 11: 캘린더 (Page 34~35)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ < 2026년 1월 > [일정추가] [일우 월일요] │
|
||||
│ [전체▼] [발주▼] [사업▼] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 일 월 화 수 목 금 토 │
|
||||
│ 1 2 3 4 5 │
|
||||
│ 6 7 8 9 10 11 12 ← 6일 선택 (주황색) │
|
||||
│ 13 14 15 16 17 18 19 토/일 배경 노란색 │
|
||||
│ 20 21 22 23 24 25 26 │
|
||||
│ 27 28 29 30 31 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1월 6일 화요일 총 4건 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ● 제목: 부서세 ✏️ │
|
||||
│ 기간: 2026-01-01~01-06 │
|
||||
│ 시간: 09:00 ~ 12:00 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ● 제목: 회의 │
|
||||
│ 기간: 2026-01-01~01-07 │
|
||||
│ 시간: 전일 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ● 제목: 1,123 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 요소 | 설명 | 클릭 동작 |
|
||||
|------|------|----------|
|
||||
| 일정추가 버튼 | 일정 추가 | (미정) |
|
||||
| 일우 월일요 버튼 | 일정/다음달 스케쥴 표시 | 일정/다음달 스케쥴 토글 |
|
||||
| 필터 셀렉트 | 전체, 발주, 사업 등 | 일정 유형 필터링 (다중선택) |
|
||||
| 날짜 클릭 | 해당 날짜 선택 | 선택 날짜 일정 목록 표시 |
|
||||
| 일정 항목 | 개별 일정 | 일정 상세 팝업 표시 |
|
||||
| 수정 아이콘 (✏️) | 일정 수정 | 일정 수정 화면으로 이동 |
|
||||
|
||||
**달력 스타일:**
|
||||
- 토요일/일요일: 배경 노란색
|
||||
- 선택된 날짜: 배경 주황색
|
||||
- 이전/다음 달: 이전달/다음달 이동
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 1. 사전 준비 ✅
|
||||
- [x] 기존 Dashboard 컴포넌트 백업 (`Dashboard.tsx.backup2`)
|
||||
- [x] 기존 MainDashboard 컴포넌트 백업 (`MainDashboard.tsx.backup`)
|
||||
- [x] CEO Dashboard 디렉토리 구조 생성
|
||||
|
||||
### 2. 컴포넌트 구조 생성
|
||||
```
|
||||
src/components/business/CEODashboard/
|
||||
├── index.tsx # 메인 컴포넌트 (export)
|
||||
├── CEODashboard.tsx # 메인 레이아웃
|
||||
├── types.ts # 타입 정의
|
||||
├── actions.ts # Server Actions
|
||||
├── sections/
|
||||
│ ├── DashboardHeader.tsx # 헤더 (항목 설정 버튼)
|
||||
│ ├── TodayIssueSection.tsx # 오늘의 이슈
|
||||
│ ├── DailyReportSection.tsx # 일일 일보
|
||||
│ ├── MonthlyExpenseSection.tsx # 당월 예상 지출 내역
|
||||
│ ├── CardManagementSection.tsx # 카드/가지급금 관리
|
||||
│ ├── EntertainmentSection.tsx # 접대비 현황
|
||||
│ ├── WelfareSection.tsx # 복리후생비 현황
|
||||
│ ├── ReceivableSection.tsx # 미수금 현황
|
||||
│ ├── DebtCollectionSection.tsx # 채권추심 현황
|
||||
│ ├── VatSection.tsx # 부가세 현황
|
||||
│ └── CalendarSection.tsx # 캘린더
|
||||
└── dialogs/ # Phase 2에서 구현
|
||||
├── ItemSettingDialog.tsx # 항목 설정 팝업
|
||||
├── DailyReportDialog.tsx # 일일 일보 정보 팝업
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 3. 섹션별 구현 체크리스트 ✅
|
||||
|
||||
#### 3.1 대시보드 헤더 ✅
|
||||
- [x] 로고 영역
|
||||
- [x] 제목 + 설명
|
||||
- [x] 항목 설정 버튼
|
||||
- [ ] 항목 설정 팝업 연동 (Phase 2)
|
||||
|
||||
#### 3.2 오늘의 이슈 ✅
|
||||
- [x] 8개 이슈 카드 그리드 (4x2)
|
||||
- [x] 각 카드 클릭 시 해당 화면 이동
|
||||
- [x] 반전 재고 빨간색 강조
|
||||
- [x] 제규 신고 D-day 표시
|
||||
|
||||
#### 3.3 일일 일보 ✅
|
||||
- [x] 날짜 표시 (년/월/일/요일)
|
||||
- [x] 4개 지표 카드
|
||||
- [x] 체크포인트 메시지 (경고/정보)
|
||||
- [ ] 클릭 시 일일 일보 팝업 (Phase 2)
|
||||
|
||||
#### 3.4 당월 예상 지출 내역 ✅
|
||||
- [x] 4개 금액 카드
|
||||
- [x] 전월 대비 증감 표시
|
||||
- [x] 체크포인트 메시지
|
||||
- [ ] 클릭 시 상세 팝업 (Phase 2)
|
||||
|
||||
#### 3.5 카드/가지급금 관리 ✅
|
||||
- [x] 4개 금액 카드
|
||||
- [x] 체크포인트 메시지
|
||||
- [x] 클릭 시 해당 화면 이동
|
||||
|
||||
#### 3.6 접대비 현황 ✅
|
||||
- [x] 4개 금액 카드
|
||||
- [x] 체크포인트 메시지
|
||||
- [ ] 클릭 시 상세 팝업 (Phase 2)
|
||||
|
||||
#### 3.7 복리후생비 현황 ✅
|
||||
- [x] 4개 금액 카드
|
||||
- [x] 체크포인트 메시지
|
||||
|
||||
#### 3.8 미수금 현황 ✅
|
||||
- [x] 4개 금액 카드 (기간별 분류)
|
||||
- [x] 매출/입금 서브 정보
|
||||
- [x] 체크포인트 메시지
|
||||
- [x] 클릭 시 미수금 상세 화면 이동
|
||||
|
||||
#### 3.9 채권추심 현황 ✅
|
||||
- [x] 4개 금액 카드
|
||||
- [x] 체크포인트 메시지
|
||||
- [x] 클릭 시 미상대금 수심관리 화면 이동
|
||||
|
||||
#### 3.10 부가세 현황 ✅
|
||||
- [x] 4개 금액 카드
|
||||
- [x] 체크포인트 메시지
|
||||
- [ ] 클릭 시 납부세액 내역 팝업 (Phase 2)
|
||||
|
||||
#### 3.11 캘린더 ✅
|
||||
- [x] ScheduleCalendar 공통 컴포넌트 활용
|
||||
- [x] 일정추가 버튼
|
||||
- [x] 필터 셀렉트 (전체/발주/사업/회의/세금)
|
||||
- [ ] 토/일 배경 노란색 스타일 커스터마이징 (추후)
|
||||
- [ ] 선택 날짜 주황색 스타일 (추후)
|
||||
- [x] 선택 날짜 일정 목록 표시
|
||||
- [ ] 일정 항목 클릭 시 상세 팝업 (Phase 2)
|
||||
- [x] 수정 아이콘 클릭 시 수정 화면 이동
|
||||
|
||||
### 4. 대시보드 교체 ✅
|
||||
- [x] Dashboard.tsx에서 MainDashboard → CEODashboard로 교체
|
||||
- [x] 타입 체크 통과
|
||||
|
||||
---
|
||||
|
||||
## 연동 페이지 목록 (오늘의 이슈 클릭 시)
|
||||
|
||||
| 이슈 항목 | 연동 페이지 | 경로 (예상) |
|
||||
|----------|------------|------------|
|
||||
| 수주 | 수주 관리 | `/sales/orders` |
|
||||
| 채권 추심 | 채권 추심 관리 | `/accounting/debt-collection` |
|
||||
| 반전 재고 | 재고 관리 | `/inventory/stock` |
|
||||
| 제규 신고 | 세무 관리 | `/accounting/tax` |
|
||||
| 신규 업체 등록 | 업체 관리 | `/partners/vendors` |
|
||||
| 연차 | 연차 관리 | `/hr/vacation` |
|
||||
| 발주 | 발주 관리 | `/purchase/orders` |
|
||||
| 결재 요청 | 결재 관리 | `/approval/pending` |
|
||||
|
||||
---
|
||||
|
||||
## 팝업 목록 (Phase 2에서 구현)
|
||||
|
||||
| 팝업 이름 | 트리거 | 내용 |
|
||||
|----------|--------|------|
|
||||
| 항목 설정 팝업 | 항목 설정 버튼 클릭 | 대시보드 표시 항목 설정 |
|
||||
| 일일 일보 정보 팝업 | 일일 일보 영역 클릭 | 일일 일보 상세 정보 |
|
||||
| 해당월 예상 지출 상세 팝업 | 당월 예상 지출 클릭 | 지출 상세 내역 |
|
||||
| 납부세액 내역 상세 팝업 | 부가세 현황 클릭 | 납부세액 상세 내역 |
|
||||
| 일정 상세 팝업 | 일정 항목 클릭 | 일정 상세 정보 |
|
||||
|
||||
---
|
||||
|
||||
## 참고 사항
|
||||
|
||||
### 기존 컴포넌트 재활용
|
||||
- `ComprehensiveAnalysis`: 많은 섹션 패턴 참고 가능
|
||||
- `SectionTitle`: 섹션 제목 컴포넌트
|
||||
- `AmountCardItem`: 금액 카드 컴포넌트
|
||||
- `CheckPointItem`: 체크포인트 메시지 컴포넌트
|
||||
- `ScheduleCalendar`: 달력 공통 컴포넌트
|
||||
- 월/주 뷰 지원
|
||||
- 이벤트/뱃지 표시
|
||||
- 커스터마이징 가능
|
||||
|
||||
### 스타일 가이드
|
||||
- 빨간색 강조: 위험/긴급 항목 (반전 재고 등)
|
||||
- 주황색: 선택된 날짜
|
||||
- 노란색 배경: 토요일/일요일
|
||||
- 체크포인트 아이콘:
|
||||
- ✅ 성공 (초록)
|
||||
- ⚠️ 경고 (주황)
|
||||
- ❌ 에러 (빨강)
|
||||
- ℹ️ 정보 (파랑)
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작업 내용 | 상태 |
|
||||
|------|----------|------|
|
||||
| 2026-01-07 | 계획서 작성 | 완료 |
|
||||
| 2026-01-07 | Phase 1 본 화면 구현 완료 (11개 섹션) | 완료 |
|
||||
@@ -0,0 +1,130 @@
|
||||
# 대시보드 항목 설정 팝업 구현 계획서
|
||||
|
||||
## 개요
|
||||
- **화면명**: 항목 설정_대시보드 팝업
|
||||
- **목적**: CEO 대시보드에 표시할 섹션들을 사용자가 ON/OFF로 선택할 수 있는 설정 팝업
|
||||
- **경로**: 대시보드 > 항목 설정 버튼 클릭 시 팝업 표시
|
||||
|
||||
## 기능 요구사항
|
||||
|
||||
### 1. 기본 구조
|
||||
- 모달/다이얼로그 형태의 팝업
|
||||
- 헤더: "항목 설정" 제목 + X 닫기 버튼
|
||||
- 푸터: 취소 | 저장 버튼
|
||||
|
||||
### 2. 섹션별 ON/OFF 토글
|
||||
|
||||
#### 오늘의 이슈 (전체 토글 + 개별 토글)
|
||||
| 항목 | 기본값 | 비고 |
|
||||
|------|--------|------|
|
||||
| 오늘의 이슈 (전체) | ON | 빨간 배경 - 전체 ON/OFF |
|
||||
| 수주 | ON | |
|
||||
| 채권 추심 | ON | |
|
||||
| 안전 재고 | ON | |
|
||||
| 세금 신고 | OFF | |
|
||||
| 신규 업체 등록 | OFF | |
|
||||
| 연차 | ON | |
|
||||
| 지각 | ON | |
|
||||
| 결근 | OFF | |
|
||||
| 발주 | OFF | |
|
||||
| 결재 요청 | OFF | |
|
||||
|
||||
#### 메인 섹션 토글 (접기/펼치기 가능)
|
||||
| 섹션 | 기본값 | 하위 설정 |
|
||||
|------|--------|----------|
|
||||
| 일일 일보 | ON | - |
|
||||
| 당월 예상 지출 내역 | ON | - |
|
||||
| 카드/가지급금 관리 | ON | - |
|
||||
| 접대비 현황 | ON | 접대비 한도 관리 (연간/분기), 기업 구분 |
|
||||
| 복리후생비 현황 | ON | 복리후생비 한도 관리, 계산 방식, 금액 설정 |
|
||||
| 미수금 현황 | ON | 미수금 상위 회사 현황 |
|
||||
| 채권추심 현황 | ON | - |
|
||||
| 부가세 현황 | ON | - |
|
||||
| 캘린더 | ON | - |
|
||||
|
||||
### 3. 상세 설정 옵션
|
||||
|
||||
#### 접대비 현황 하위 설정
|
||||
- 접대비 한도 관리: 연간 / 분기 선택 (드롭다운)
|
||||
- 기업 구분: 기업 선택 (드롭다운) + 설명 버튼
|
||||
|
||||
#### 복리후생비 현황 하위 설정
|
||||
- 복리후생비 한도 관리: 연간 / 분기 선택 (드롭다운)
|
||||
- 계산 방식: 직원당 정해 금액 방식 / 연봉 총액 X 비율 방식 (드롭다운)
|
||||
- 직원당 정해 금액/월: 금액 입력 (계산 방식이 "직원당 정해 금액 방식"일 때)
|
||||
- 비율: % 입력 (계산 방식이 "연봉 총액 X 비율 방식"일 때)
|
||||
- 연간 복리후생비총액: 자동 계산 또는 직접 입력
|
||||
|
||||
### 4. 기업 구분 설명 패널
|
||||
- 1-2 버튼 클릭 시 기업 구분 기준 설명 펼침/접힘
|
||||
- 중소기업 판단 기준 설명 (자본총액 기준, 매출액 기준)
|
||||
- 정보 제공용 (읽기 전용)
|
||||
|
||||
### 5. 데이터 저장
|
||||
- localStorage 또는 API를 통한 설정 저장
|
||||
- 저장 버튼 클릭 시 설정 적용 및 대시보드 새로고침
|
||||
- 취소 버튼 클릭 시 변경사항 무시하고 팝업 닫기
|
||||
|
||||
---
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 기본 팝업 구조
|
||||
- [x] 1.1 DashboardSettingsDialog 컴포넌트 생성
|
||||
- [x] 1.2 타입 정의 (DashboardSettings 인터페이스)
|
||||
- [x] 1.3 기본 다이얼로그 UI 구현 (헤더, 푸터)
|
||||
- [x] 1.4 CEODashboard에서 팝업 연결
|
||||
|
||||
### Phase 2: 오늘의 이슈 섹션
|
||||
- [x] 2.1 전체 토글 (빨간 배경) 구현
|
||||
- [x] 2.2 개별 항목 토글 목록 구현
|
||||
- [x] 2.3 전체 토글 연동 (전체 OFF 시 개별 모두 OFF)
|
||||
|
||||
### Phase 3: 메인 섹션 토글
|
||||
- [x] 3.1 접기/펼치기 가능한 섹션 아코디언 구현
|
||||
- [x] 3.2 일일 일보 ~ 캘린더 섹션 토글 구현
|
||||
- [x] 3.3 섹션별 ON/OFF 상태 관리
|
||||
|
||||
### Phase 4: 상세 설정 옵션
|
||||
- [x] 4.1 접대비 현황 하위 설정 (한도 관리, 기업 구분)
|
||||
- [x] 4.2 복리후생비 현황 하위 설정 (한도 관리, 계산 방식, 금액)
|
||||
- [ ] 4.3 기업 구분 설명 패널 (펼침/접힘) - 기획서 확인 후 추가 구현 필요
|
||||
|
||||
### Phase 5: 데이터 연동
|
||||
- [x] 5.1 설정 상태 관리 (useState/useReducer)
|
||||
- [x] 5.2 localStorage 저장/불러오기
|
||||
- [x] 5.3 대시보드에 설정 적용 (조건부 렌더링)
|
||||
|
||||
### Phase 6: 마무리
|
||||
- [x] 6.1 스타일 정리 및 반응형 대응
|
||||
- [ ] 6.2 테스트 및 검증 (빌드 확인 필요)
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/components/business/CEODashboard/
|
||||
├── CEODashboard.tsx (수정 완료)
|
||||
├── components.tsx
|
||||
├── types.ts (수정 완료 - 설정 타입 추가)
|
||||
├── dialogs/
|
||||
│ └── DashboardSettingsDialog.tsx (신규 생성 완료)
|
||||
├── hooks/
|
||||
│ └── useDashboardSettings.ts (필요 시 추가)
|
||||
└── sections/
|
||||
└── ... (기존)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고사항
|
||||
- 기획서 Description 영역의 번호(01, 02 등)는 설명용이므로 UI에 구현하지 않음
|
||||
- 디자인은 프로젝트 기존 Dialog/Switch 컴포넌트 패턴 따름
|
||||
|
||||
## 구현 완료 (2026-01-08)
|
||||
- DashboardSettingsDialog 컴포넌트 생성
|
||||
- 커스텀 ToggleSwitch 컴포넌트 (ON/OFF 라벨, 색상 지원)
|
||||
- Collapsible 기반 아코디언 섹션 구현
|
||||
- localStorage 기반 설정 영속화
|
||||
- 대시보드 섹션 조건부 렌더링 적용
|
||||
125
claudedocs/[NEXT-2026-01-08] ceo-dashboard-session-context.md
Normal file
125
claudedocs/[NEXT-2026-01-08] ceo-dashboard-session-context.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# CEO Dashboard 세션 컨텍스트 (2026-01-08)
|
||||
|
||||
## 세션 요약
|
||||
|
||||
### 완료된 작업
|
||||
- [x] 세금 신고 카드: "3건" → "부가세 신고 D-15" (건수 제거)
|
||||
- [x] 오늘의 이슈 카드: StatCards 스타일로 변경
|
||||
- [x] 문자열 count 스타일: `text-xl md:text-2xl font-medium` (작고 덜 굵게)
|
||||
- [x] 새로고침 버튼 제거
|
||||
- [x] 항목 설정 버튼 → 페이지 헤더 오른쪽으로 이동
|
||||
|
||||
### 수정된 파일
|
||||
- `src/components/business/CEODashboard/CEODashboard.tsx` - 데이터, 버튼 위치
|
||||
- `src/components/business/CEODashboard/components.tsx` - IssueCardItem StatCards 스타일
|
||||
- `src/components/business/CEODashboard/sections/TodayIssueSection.tsx` - 항목 설정 버튼 제거
|
||||
- `src/components/business/CEODashboard/types.ts` - icon prop 추가
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 TODO
|
||||
|
||||
### 1. 기획서 vs 구현 비교 점검
|
||||
- [ ] 기획서 스크린샷과 현재 구현 1:1 비교
|
||||
- [ ] 누락된 요소 확인
|
||||
- [ ] 임의 추가된 요소 제거
|
||||
- [ ] 빌드 확인
|
||||
|
||||
### 2. 기획서 기반 구현 정확도 개선 (우선순위 높음)
|
||||
|
||||
#### 방안 A: RULES.md 강화
|
||||
**위치**: `~/.claude/RULES.md` - "Scope Discipline & Visual Reference Fidelity" 섹션
|
||||
|
||||
**추가할 규칙**:
|
||||
```markdown
|
||||
### 기획서/스크린샷 기반 구현 프로세스
|
||||
**Priority**: 🔴 **Triggers**: 기획서, 스크린샷, PDF 제공 시
|
||||
|
||||
**필수 단계**:
|
||||
1. **요소 추출**: 스크린샷에서 모든 UI 요소 목록화
|
||||
- 버튼, 텍스트, 카드, 아이콘 등 식별
|
||||
- 위치, 스타일, 동작 기록
|
||||
2. **사용자 확인**: "이 요소들 맞아?" 확인 요청
|
||||
3. **기존 패턴 검색**: 프로젝트 내 유사 컴포넌트 찾기
|
||||
4. **구현**: 기획서 요소만 구현 (임의 추가 금지)
|
||||
5. **검증 체크리스트**: 구현 후 기획서 vs 결과 비교표 제시
|
||||
|
||||
**금지 사항**:
|
||||
- ❌ 기획서에 없는 버튼/기능 임의 추가 (예: 새로고침 버튼)
|
||||
- ❌ 기획서와 다른 위치에 요소 배치
|
||||
- ❌ "있으면 좋겠다" 기반 추가 기능
|
||||
```
|
||||
|
||||
#### 방안 B: 스킬 생성 (`/sc:implement-ui`)
|
||||
**위치**: `~/.claude/commands/sc_implement-ui.md`
|
||||
|
||||
**스킬 플로우**:
|
||||
```
|
||||
/sc:implement-ui @screenshot.png
|
||||
|
||||
1. [분석] 스크린샷에서 UI 요소 추출
|
||||
- 버튼: [목록]
|
||||
- 카드: [목록]
|
||||
- 텍스트: [목록]
|
||||
- 레이아웃: [설명]
|
||||
|
||||
2. [확인] 사용자에게 요소 목록 확인 요청
|
||||
"이 요소들이 맞나요? 누락/추가할 것 있나요?"
|
||||
|
||||
3. [패턴 검색] 기존 프로젝트에서 유사 컴포넌트 찾기
|
||||
- 검색 결과 제시
|
||||
- 재사용할 패턴 선택
|
||||
|
||||
4. [구현] 기획서 요소만 구현
|
||||
- 임의 추가 금지
|
||||
- 기존 패턴 따르기
|
||||
|
||||
5. [검증] 기획서 vs 구현 비교 체크리스트
|
||||
| 기획서 요소 | 구현 여부 | 위치 일치 | 스타일 일치 |
|
||||
|------------|----------|----------|------------|
|
||||
| 항목 설정 버튼 | ✅ | ✅ | ✅ |
|
||||
| 새로고침 버튼 | ❌ (없음) | - | - |
|
||||
```
|
||||
|
||||
**스킬 파일 예시**:
|
||||
```markdown
|
||||
# /sc:implement-ui - 기획서 기반 UI 구현
|
||||
|
||||
## 목적
|
||||
스크린샷/기획서를 정확하게 구현하기 위한 체계적 워크플로우
|
||||
|
||||
## 사용법
|
||||
/sc:implement-ui @screenshot.png
|
||||
/sc:implement-ui @design.pdf "특정 섹션 설명"
|
||||
|
||||
## 프로세스
|
||||
[위 플로우 내용]
|
||||
|
||||
## 검증 규칙
|
||||
- 기획서에 있는 것만 구현
|
||||
- 없는 것은 절대 추가하지 않음
|
||||
- 구현 후 반드시 비교 체크리스트 제시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제점 분석 (이번 세션에서 발생한 이슈)
|
||||
|
||||
### 발생한 문제
|
||||
1. **새로고침 버튼**: 기획서에 없는데 임의 추가
|
||||
2. **항목 설정 버튼 위치**: 기획서와 다른 위치에 배치
|
||||
3. **세금 신고 카드**: 기획서에 건수 없는데 "3건" 추가
|
||||
|
||||
### 원인
|
||||
- 기획서 꼼꼼히 확인 안 함
|
||||
- "있으면 좋겠다" 기반 임의 추가
|
||||
- 구현 전 요소 목록화 단계 누락
|
||||
|
||||
### 해결책
|
||||
- RULES.md 강화 + 스킬 생성으로 프로세스 강제
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일
|
||||
- 기획서: `/Users/byeongcheolryu/Desktop/스크린샷 2026-01-07 오후 6.55.10.png`
|
||||
- 체크리스트: `claudedocs/[IMPL-2026-01-07] ceo-dashboard-checklist.md`
|
||||
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-02)
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-07)
|
||||
|
||||
## ⭐ 빠른 참조
|
||||
|
||||
@@ -131,6 +131,7 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[IMPL-2026-01-07] ceo-dashboard-checklist.md` | 🔴 **NEW** - 대표님 전용 대시보드 구현 체크리스트 (11개 섹션, 달력 포함) |
|
||||
| `dashboard-integration-complete.md` | 대시보드 통합 완료 |
|
||||
| `dashboard-cleanup-summary.md` | 정리 요약 |
|
||||
| `dashboard-migration-summary.md` | 마이그레이션 요약 |
|
||||
@@ -154,7 +155,8 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[DESIGN-2026-01-02] document-modal-common-component.md` | 🔴 **NEW** - 문서 모달 공통 컴포넌트 설계 요구사항 (6개 모달 분석, 헤더/결재라인/테이블 조합형) |
|
||||
| `[REF-2026-01-07] nextjs-security-update-and-migration-plan.md` | 🔴 **NEW** - Next.js 보안 업데이트 (15.5.9) 및 16 마이그레이션 계획 |
|
||||
| `[DESIGN-2026-01-02] document-modal-common-component.md` | 문서 모달 공통 컴포넌트 설계 요구사항 (6개 모달 분석, 헤더/결재라인/테이블 조합형) |
|
||||
| `[GUIDE] print-area-utility.md` | 인쇄 모달 printArea 유틸리티 가이드 (8개 모달 적용, print-utils.ts) |
|
||||
| `[GUIDE-2025-12-29] vercel-deployment.md` | Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
|
||||
| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) |
|
||||
|
||||
@@ -60,6 +60,10 @@ src/lib/api/
|
||||
|
||||
src/app/api/proxy/
|
||||
└── [...path]/route.ts # Proxy (import from refresh-token)
|
||||
|
||||
src/app/api/auth/
|
||||
├── check/route.ts # 🔧 인증 확인 API (2026-01-08 통합)
|
||||
└── refresh/route.ts # 🔧 토큰 갱신 API (2026-01-08 통합)
|
||||
```
|
||||
|
||||
### 3.2 공통 모듈: `refresh-token.ts`
|
||||
@@ -302,12 +306,14 @@ Safari에서 `SameSite=Strict` + `Secure` 조합이 localhost에서 쿠키 저
|
||||
|
||||
## 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` | 로그인 및 초기 토큰 설정 |
|
||||
| 파일 | 역할 | 통합일 |
|
||||
|------|------|--------|
|
||||
| `src/lib/api/refresh-token.ts` | 공통 토큰 갱신 모듈 (캐싱 로직) | 2025-12-30 |
|
||||
| `src/lib/api/fetch-wrapper.ts` | Server Actions용 fetch wrapper | 2025-12-30 |
|
||||
| `src/app/api/proxy/[...path]/route.ts` | 클라이언트 API 프록시 | 2025-12-30 |
|
||||
| `src/app/api/auth/login/route.ts` | 로그인 및 초기 토큰 설정 | - |
|
||||
| `src/app/api/auth/check/route.ts` | 인증 상태 확인 API | 2026-01-08 |
|
||||
| `src/app/api/auth/refresh/route.ts` | 토큰 갱신 프록시 API | 2026-01-08 |
|
||||
|
||||
---
|
||||
|
||||
@@ -326,4 +332,84 @@ Safari에서 `SameSite=Strict` + `Secure` 조합이 localhost에서 쿠키 저
|
||||
### 9.3 향후 위험성 없음
|
||||
- 5초 TTL은 충분히 짧아 토큰 갱신 지연 문제 없음
|
||||
- 실패 시 다음 요청에서 새로 갱신 시도
|
||||
- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화
|
||||
- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화
|
||||
|
||||
---
|
||||
|
||||
## 10. 업데이트 이력
|
||||
|
||||
### 10.1 [2026-01-08] 누락된 API 라우트 통합
|
||||
|
||||
**문제 발견:**
|
||||
`/api/auth/check`와 `/api/auth/refresh` 라우트가 공유 캐시를 사용하지 않고 자체 fetch 로직을 사용하고 있었음.
|
||||
|
||||
**증상:**
|
||||
```
|
||||
🔍 Refresh API response status: 401
|
||||
❌ Refresh API failed: 401 {"error":"리프레시 토큰이 유효하지 않거나 만료되었습니다","error_code":"TOKEN_EXPIRED"}
|
||||
⚠️ Returning 401 due to refresh failure
|
||||
GET /api/auth/check 401
|
||||
```
|
||||
|
||||
**원인:**
|
||||
1. `serverFetch`에서 refresh 성공 → Token Rotation으로 이전 refresh_token 폐기
|
||||
2. `/api/auth/check`가 동시에 호출됨
|
||||
3. 자체 fetch 로직으로 이미 폐기된 토큰 사용 시도 → 실패 → 로그인 페이지 이동
|
||||
|
||||
**해결:**
|
||||
두 파일 모두 `refreshAccessToken()` 공유 함수를 사용하도록 수정:
|
||||
|
||||
```typescript
|
||||
// src/app/api/auth/check/route.ts
|
||||
import { refreshAccessToken } from '@/lib/api/refresh-token';
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'auth/check');
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/app/api/auth/refresh/route.ts
|
||||
import { refreshAccessToken } from '@/lib/api/refresh-token';
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'api/auth/refresh');
|
||||
```
|
||||
|
||||
**결과:**
|
||||
모든 refresh 경로가 동일한 5초 캐시를 공유하여 Token Rotation 충돌 방지.
|
||||
|
||||
### 10.2 [2026-01-08] 53개 Server Actions 파일 수정
|
||||
|
||||
**문제:**
|
||||
`redirect('/login')` 호출 시 발생하는 `NEXT_REDIRECT` 에러가 catch 블록에서 잡혀 `{ success: false }` 반환 → 무한 루프
|
||||
|
||||
**해결:**
|
||||
모든 actions.ts 파일에 `isRedirectError` 처리 추가:
|
||||
|
||||
```typescript
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
// ... 기존 에러 처리
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 [2026-01-08] refresh 실패 결과 캐시 버그 수정
|
||||
|
||||
**문제:**
|
||||
refresh 실패 결과도 5초간 캐시되어, 후속 요청들이 모두 실패 결과를 받음.
|
||||
|
||||
**해결:**
|
||||
`refresh-token.ts`에서 성공한 결과만 캐시하도록 수정:
|
||||
|
||||
```typescript
|
||||
// 1. 캐시된 성공 결과가 유효하면 즉시 반환
|
||||
if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
|
||||
return refreshCache.result;
|
||||
}
|
||||
|
||||
// 2-1. 이전 refresh가 실패했으면 캐시 초기화
|
||||
if (refreshCache.result && !refreshCache.result.success) {
|
||||
refreshCache.promise = null;
|
||||
refreshCache.result = null;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
# Next.js 보안 업데이트 및 마이그레이션 계획
|
||||
|
||||
## 현재 상태 (2026-01-07)
|
||||
|
||||
### 적용된 버전
|
||||
| 패키지 | 이전 버전 | 현재 버전 | 상태 |
|
||||
|--------|-----------|-----------|------|
|
||||
| next | 15.5.7 | **15.5.9** | ✅ 보안 패치 완료 |
|
||||
| react | 19.2.1 | **19.2.3** | ✅ 보안 패치 완료 |
|
||||
| react-dom | 19.2.1 | **19.2.3** | ✅ 보안 패치 완료 |
|
||||
|
||||
### 해결된 취약점
|
||||
| CVE | 심각도 | 내용 | 상태 |
|
||||
|-----|--------|------|------|
|
||||
| CVE-2025-55184 | HIGH (7.5) | DoS - 무한 루프로 서버 중단 | ✅ 해결 |
|
||||
| CVE-2025-55183 | MEDIUM (5.3) | Server Functions 소스코드 노출 | ✅ 해결 |
|
||||
| CVE-2025-67779 | HIGH | CVE-2025-55184 완전 수정 | ✅ 해결 |
|
||||
|
||||
### 남은 취약점
|
||||
| 패키지 | 심각도 | 내용 | 우선순위 |
|
||||
|--------|--------|------|----------|
|
||||
| js-yaml | MODERATE | Prototype Pollution (간접 의존성) | 낮음 |
|
||||
|
||||
---
|
||||
|
||||
## Next.js 16 마이그레이션 계획
|
||||
|
||||
### 예상 작업량
|
||||
- **예상 소요 시간**: 4-8시간
|
||||
- **영향 파일 수**: 약 40개
|
||||
|
||||
### Breaking Changes 영향 분석
|
||||
|
||||
#### 1. middleware.ts → proxy.ts 변경 (중간 난이도)
|
||||
```
|
||||
영향 파일: src/middleware.ts (316줄)
|
||||
작업 내용:
|
||||
- 파일명 변경: middleware.ts → proxy.ts
|
||||
- 함수명 변경: export function middleware → export function proxy
|
||||
- next-intl 호환: 이미 지원됨
|
||||
```
|
||||
|
||||
#### 2. Async Request APIs (가장 큰 작업)
|
||||
```
|
||||
영향 파일: 36개
|
||||
수정 필요 위치: 52곳
|
||||
|
||||
변경 전 (현재 패턴):
|
||||
const eventId = params.id as string;
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
변경 후 (Next.js 16 패턴):
|
||||
const { id } = await params;
|
||||
const searchParamsResolved = await searchParams;
|
||||
const mode = searchParamsResolved.get('mode');
|
||||
```
|
||||
|
||||
**영향받는 주요 영역**:
|
||||
| 영역 | 파일 수 |
|
||||
|------|---------|
|
||||
| /accounting/* | 10개 |
|
||||
| /hr/* | 6개 |
|
||||
| /sales/* | 8개 |
|
||||
| /boards/* | 6개 |
|
||||
| 기타 | 6개 |
|
||||
|
||||
#### 3. Turbopack 기본값 (영향 없음)
|
||||
- `next.config.ts`에 이미 `turbopack: {}` 설정 있음
|
||||
- 커스텀 Webpack 설정 없음 → 호환 OK
|
||||
|
||||
#### 4. cookies() 호출 (이미 호환)
|
||||
- `src/lib/api/fetch-wrapper.ts`에서 `await cookies()` 사용 중
|
||||
- 추가 수정 불필요
|
||||
|
||||
### 마이그레이션 절차
|
||||
|
||||
```bash
|
||||
# 1. feature 브랜치 생성
|
||||
git checkout -b feature/nextjs-16-migration
|
||||
|
||||
# 2. 자동 마이그레이션 도구 실행
|
||||
npx @next/codemod@canary upgrade latest
|
||||
|
||||
# 3. 수동 확인 및 수정
|
||||
# - middleware.ts → proxy.ts 변경
|
||||
# - params/searchParams async 변환 확인
|
||||
|
||||
# 4. 빌드 테스트
|
||||
npm run build
|
||||
|
||||
# 5. 로컬 테스트
|
||||
npm run dev
|
||||
|
||||
# 6. PR 생성 및 리뷰
|
||||
```
|
||||
|
||||
### 마이그레이션 체크리스트
|
||||
- [ ] feature 브랜치 생성
|
||||
- [ ] codemod 실행
|
||||
- [ ] middleware.ts → proxy.ts 변경
|
||||
- [ ] 함수명 middleware → proxy 변경
|
||||
- [ ] params/searchParams async 변환 (36개 파일)
|
||||
- [ ] 빌드 테스트 통과
|
||||
- [ ] 주요 페이지 동작 테스트
|
||||
- [ ] PR 생성 및 머지
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
### 공식 문서
|
||||
- [Next.js 16 Release Blog](https://nextjs.org/blog/next-16)
|
||||
- [Version 16 Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-16)
|
||||
- [next-intl Middleware/Proxy Docs](https://next-intl.dev/docs/routing/middleware)
|
||||
|
||||
### 보안 권고
|
||||
- [Next.js Security Update Dec 11, 2025](https://nextjs.org/blog/security-update-2025-12-11)
|
||||
- [React DoS and Source Code Exposure](https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components)
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작업 | 담당 |
|
||||
|------|------|------|
|
||||
| 2026-01-07 | 보안 패치 적용 (15.5.9, 19.2.3) | Claude |
|
||||
| - | Next.js 16 마이그레이션 | 예정 |
|
||||
@@ -1,28 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { MapPin, X, Clock, User } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import GoogleMap from '@/components/attendance/GoogleMap';
|
||||
import AttendanceComplete from '@/components/attendance/AttendanceComplete';
|
||||
import { checkIn, checkOut, getTodayAttendance } from '@/components/attendance/actions';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// ========================================
|
||||
// 하드코딩 설정값 (MVP - 추후 API로 대체)
|
||||
// ========================================
|
||||
// TODO: 이 값들은 출퇴근관리 설정 페이지에서 관리됨
|
||||
// 설정 페이지 경로: /settings/attendance-settings
|
||||
// API 연동 시: GET /api/settings/attendance 에서 조회
|
||||
// ────────────────────────────────────────
|
||||
// - radius: 출퇴근관리 설정의 allowedRadius 값 사용
|
||||
// - gpsDepartments: 로그인 사용자의 부서가 포함되어 있는지 체크
|
||||
// - gpsEnabled: false면 GPS 출퇴근 기능 비활성화
|
||||
// ────────────────────────────────────────
|
||||
const SITE_LOCATION = {
|
||||
name: '본사',
|
||||
lat: 37.557358,
|
||||
lng: 126.864414,
|
||||
radius: 100, // meters → 출퇴근관리 설정의 allowedRadius 값으로 대체 예정
|
||||
radius: 100,
|
||||
};
|
||||
|
||||
const TEST_USER = {
|
||||
@@ -31,15 +24,12 @@ const TEST_USER = {
|
||||
position: '직급명',
|
||||
};
|
||||
|
||||
// 출퇴근 상태 타입
|
||||
type AttendanceStatus = 'not-checked-in' | 'checked-in' | 'checked-out';
|
||||
type ViewMode = 'main' | 'check-in-complete' | 'check-out-complete';
|
||||
|
||||
export default function MobileAttendancePage() {
|
||||
// Hydration 에러 방지: 클라이언트 마운트 상태
|
||||
export default function AttendancePage() {
|
||||
const router = useRouter();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 상태 관리
|
||||
const [currentTime, setCurrentTime] = useState<string>('--:--:--');
|
||||
const [currentDate, setCurrentDate] = useState<string>('');
|
||||
const [distance, setDistance] = useState<number | null>(null);
|
||||
@@ -54,21 +44,27 @@ export default function MobileAttendancePage() {
|
||||
const [userPosition, setUserPosition] = useState(TEST_USER.position);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// 클라이언트 마운트 확인
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 오늘의 근태 상태 조회 (userId가 설정된 후에 조회)
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || userId === null) return;
|
||||
|
||||
const fetchTodayAttendance = async () => {
|
||||
try {
|
||||
const result = await getTodayAttendance();
|
||||
if (result.success && result.data) {
|
||||
// 이미 출근한 경우
|
||||
if (result.data.checkIn) {
|
||||
setCheckInTime(result.data.checkIn);
|
||||
setAttendanceStatus(result.data.checkOut ? 'checked-out' : 'checked-in');
|
||||
@@ -78,25 +74,20 @@ export default function MobileAttendancePage() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MobileAttendancePage] fetchTodayAttendance error:', error);
|
||||
console.error('[AttendancePage] fetchTodayAttendance error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTodayAttendance();
|
||||
}, [mounted, userId]);
|
||||
|
||||
// 현재 시간 업데이트 (마운트 후에만 실행)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
setCurrentTime(`${hours}:${minutes}:${seconds}`);
|
||||
|
||||
// 날짜 포맷
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
@@ -104,16 +95,13 @@ export default function MobileAttendancePage() {
|
||||
const day = dayNames[now.getDay()];
|
||||
setCurrentDate(`${year}년 ${month}월 ${date}일 (${day})`);
|
||||
};
|
||||
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [mounted]);
|
||||
|
||||
// localStorage에서 사용자 정보 가져오기 (마운트 후에만 실행)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const userDataStr = localStorage.getItem('user');
|
||||
if (userDataStr) {
|
||||
try {
|
||||
@@ -128,171 +116,171 @@ export default function MobileAttendancePage() {
|
||||
}
|
||||
}, [mounted]);
|
||||
|
||||
// 거리 변경 콜백
|
||||
const handleDistanceChange = useCallback((dist: number, inRange: boolean, location?: { lat: number; lng: number }) => {
|
||||
setDistance(dist);
|
||||
setIsInRange(inRange);
|
||||
if (location) {
|
||||
setUserLocation(location);
|
||||
}
|
||||
if (location) setUserLocation(location);
|
||||
}, []);
|
||||
|
||||
// 출근하기
|
||||
const handleCheckIn = async () => {
|
||||
if (!isInRange || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeStr = `${hours}:${minutes}:${seconds}`;
|
||||
|
||||
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
try {
|
||||
const result = await checkIn({
|
||||
checkIn: timeStr,
|
||||
gpsData: userLocation
|
||||
? {
|
||||
latitude: userLocation.lat,
|
||||
longitude: userLocation.lng,
|
||||
}
|
||||
: undefined,
|
||||
gpsData: userLocation ? { latitude: userLocation.lat, longitude: userLocation.lng } : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setCheckInTime(timeStr);
|
||||
setAttendanceStatus('checked-in');
|
||||
setViewMode('check-in-complete');
|
||||
} else {
|
||||
console.error('[MobileAttendancePage] Check-in failed:', result.error);
|
||||
// TODO: 에러 토스트 표시
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MobileAttendancePage] Check-in error:', error);
|
||||
console.error('[AttendancePage] Check-in error:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 퇴근하기
|
||||
const handleCheckOut = async () => {
|
||||
if (!isInRange || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
const timeStr = `${hours}:${minutes}:${seconds}`;
|
||||
|
||||
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
try {
|
||||
const result = await checkOut({
|
||||
checkOut: timeStr,
|
||||
gpsData: userLocation
|
||||
? {
|
||||
latitude: userLocation.lat,
|
||||
longitude: userLocation.lng,
|
||||
}
|
||||
: undefined,
|
||||
gpsData: userLocation ? { latitude: userLocation.lat, longitude: userLocation.lng } : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setCheckOutTime(timeStr);
|
||||
setAttendanceStatus('checked-out');
|
||||
setViewMode('check-out-complete');
|
||||
} else {
|
||||
console.error('[MobileAttendancePage] Check-out failed:', result.error);
|
||||
// TODO: 에러 토스트 표시
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MobileAttendancePage] Check-out error:', error);
|
||||
console.error('[AttendancePage] Check-out error:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 완료 화면에서 확인 클릭
|
||||
const handleConfirm = () => {
|
||||
setViewMode('main');
|
||||
};
|
||||
const handleConfirm = () => setViewMode('main');
|
||||
const handleClose = () => router.back();
|
||||
|
||||
// 마운트 전 로딩 UI (Hydration 에러 방지)
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-100px)]">
|
||||
<div className="text-center py-3 border-b bg-white">
|
||||
<h1 className="text-lg font-semibold">출퇴근하기</h1>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-100">
|
||||
<div className="flex h-[calc(100vh-120px)] bg-background">
|
||||
<div className="flex-1 flex items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-gray-500 text-sm">로딩 중...</p>
|
||||
<p className="text-muted-foreground text-sm">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 완료 화면 렌더링
|
||||
if (viewMode === 'check-in-complete') {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)]">
|
||||
<AttendanceComplete
|
||||
type="check-in"
|
||||
time={checkInTime}
|
||||
date={currentDate}
|
||||
location={SITE_LOCATION.name}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
<div className="h-[calc(100vh-120px)]">
|
||||
<AttendanceComplete type="check-in" time={checkInTime} date={currentDate} location={SITE_LOCATION.name} onConfirm={handleConfirm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'check-out-complete') {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)]">
|
||||
<AttendanceComplete
|
||||
type="check-out"
|
||||
time={checkOutTime}
|
||||
date={currentDate}
|
||||
location={SITE_LOCATION.name}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
<div className="h-[calc(100vh-120px)]">
|
||||
<AttendanceComplete type="check-out" time={checkOutTime} date={currentDate} location={SITE_LOCATION.name} onConfirm={handleConfirm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 활성화 상태
|
||||
// - 출근: 아직 출근 안 했거나, 퇴근한 경우 (다시 출근 가능)
|
||||
// - 퇴근: 출근한 경우에만 가능
|
||||
const canCheckIn = isInRange && attendanceStatus !== 'checked-in' && !isProcessing;
|
||||
const canCheckOut = isInRange && attendanceStatus === 'checked-in' && !isProcessing;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-100px)]">
|
||||
{/* 타이틀 */}
|
||||
<div className="text-center py-3 border-b bg-white">
|
||||
<h1 className="text-lg font-semibold">출퇴근하기</h1>
|
||||
// 모바일 레이아웃
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||
<div className="flex-1 relative">
|
||||
<GoogleMap siteLocation={SITE_LOCATION} onDistanceChange={handleDistanceChange} />
|
||||
{distance !== null && (
|
||||
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg shadow-md">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<span className={isInRange ? 'text-green-600 font-medium' : 'text-gray-600'}>
|
||||
{distance < 1000 ? `${Math.round(distance)}m` : `${(distance / 1000).toFixed(1)}km`}
|
||||
{isInRange && ' (범위 내)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border-t p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">출퇴근하기</h2>
|
||||
<p className="text-sm text-muted-foreground">현재 위치에서 출퇴근을 기록하세요</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} className="p-2">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{userName}</p>
|
||||
<p className="text-sm text-muted-foreground">{userDepartment} {userPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>현재 시간</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-foreground" suppressHydrationWarning>{currentTime}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={attendanceStatus === 'checked-in' ? handleCheckOut : handleCheckIn}
|
||||
disabled={attendanceStatus === 'checked-in' ? !canCheckOut : !canCheckIn}
|
||||
className={`w-full h-12 text-base font-medium rounded-xl transition-all ${
|
||||
(attendanceStatus === 'checked-in' ? canCheckOut : canCheckIn)
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{isProcessing ? '처리 중...' : attendanceStatus === 'checked-in' ? '퇴근하기' : '출근하기'}
|
||||
</Button>
|
||||
|
||||
{!isInRange && distance !== null && (
|
||||
<p className="text-center text-sm text-orange-500">출퇴근 가능 범위({SITE_LOCATION.radius}m) 밖에 있습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 지도 영역 */}
|
||||
// PC 레이아웃 - 좌측 지도 + 우측 패널
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-120px)] bg-background rounded-xl overflow-hidden">
|
||||
<div className="flex-1 relative">
|
||||
<GoogleMap
|
||||
siteLocation={SITE_LOCATION}
|
||||
onDistanceChange={handleDistanceChange}
|
||||
/>
|
||||
|
||||
{/* 거리 표시 오버레이 */}
|
||||
<GoogleMap siteLocation={SITE_LOCATION} onDistanceChange={handleDistanceChange} />
|
||||
{distance !== null && (
|
||||
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg shadow-md">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur px-4 py-2 rounded-xl shadow-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<span className={isInRange ? 'text-green-600 font-medium' : 'text-gray-600'}>
|
||||
{distance < 1000
|
||||
? `${Math.round(distance)}m`
|
||||
: `${(distance / 1000).toFixed(1)}km`}
|
||||
{distance < 1000 ? `${Math.round(distance)}m` : `${(distance / 1000).toFixed(1)}km`}
|
||||
{isInRange && ' (범위 내)'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -300,62 +288,60 @@ export default function MobileAttendancePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 + 시간 + 버튼 */}
|
||||
<div className="bg-white border-t p-4 space-y-4">
|
||||
{/* 사용자 정보 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<span className="text-gray-600 font-medium">{userName.charAt(0)}</span>
|
||||
</div>
|
||||
<div className="w-[400px] bg-background border-l flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{userName}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{userDepartment} {userPosition}
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold">출퇴근하기</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">현재 위치에서 출퇴근을 기록하세요</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} className="p-2 hover:bg-muted rounded-lg">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 현재 시간 */}
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-red-500" suppressHydrationWarning>{currentTime}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5" suppressHydrationWarning>{currentDate}</p>
|
||||
{attendanceStatus === 'checked-in' && (
|
||||
<p className="text-sm text-green-600 mt-1">출근중</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-7 w-7 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-foreground">{userName}</p>
|
||||
<p className="text-sm text-muted-foreground">{userDepartment} · {userPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950/30 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>현재 시간</span>
|
||||
</div>
|
||||
<p className="text-4xl font-bold text-foreground" suppressHydrationWarning>{currentTime}</p>
|
||||
</div>
|
||||
|
||||
{/* 출근/퇴근 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleCheckIn}
|
||||
disabled={!canCheckIn}
|
||||
className={`flex-1 h-12 text-base font-medium rounded-lg transition-all ${
|
||||
canCheckIn
|
||||
onClick={attendanceStatus === 'checked-in' ? handleCheckOut : handleCheckIn}
|
||||
disabled={attendanceStatus === 'checked-in' ? !canCheckOut : !canCheckIn}
|
||||
className={`w-full h-14 text-lg font-medium rounded-xl transition-all ${
|
||||
(attendanceStatus === 'checked-in' ? canCheckOut : canCheckIn)
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isProcessing && attendanceStatus === 'not-checked-in' ? '처리 중...' : '출근하기'}
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{isProcessing ? '처리 중...' : attendanceStatus === 'checked-in' ? '퇴근하기' : '출근하기'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCheckOut}
|
||||
disabled={!canCheckOut}
|
||||
className={`flex-1 h-12 text-base font-medium rounded-lg transition-all ${
|
||||
canCheckOut
|
||||
? 'bg-gray-800 hover:bg-gray-900 text-white'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isProcessing && attendanceStatus === 'checked-in' ? '처리 중...' : '퇴근하기'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 범위 밖 경고 */}
|
||||
{!isInRange && distance !== null && (
|
||||
<p className="text-center text-sm text-orange-500">
|
||||
출퇴근 가능 범위({SITE_LOCATION.radius}m) 밖에 있습니다.
|
||||
</p>
|
||||
)}
|
||||
{!isInRange && distance !== null && (
|
||||
<p className="text-center text-sm text-orange-500">출퇴근 가능 범위({SITE_LOCATION.radius}m) 밖에 있습니다.</p>
|
||||
)}
|
||||
|
||||
{attendanceStatus === 'checked-in' && (
|
||||
<div className="bg-green-50 dark:bg-green-950/30 rounded-xl p-4 text-center">
|
||||
<p className="text-green-600 font-medium">출근 완료</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">출근 시간: {checkInTime}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function QualityInspectionPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-[calc(100vh-64px)] p-6 bg-slate-100 flex flex-col overflow-hidden">
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<Filters
|
||||
@@ -110,9 +110,9 @@ export default function QualityInspectionPage() {
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-12 gap-6 min-h-0">
|
||||
<div className="flex-1 grid grid-cols-12 gap-6 lg:min-h-0">
|
||||
{/* Left Panel: Report List */}
|
||||
<div className="col-span-12 lg:col-span-3 h-full overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
selectedId={selectedReport?.id || null}
|
||||
@@ -121,7 +121,7 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
|
||||
{/* Middle Panel: Route List */}
|
||||
<div className="col-span-12 lg:col-span-4 h-full overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<RouteList
|
||||
routes={currentRoutes}
|
||||
selectedId={selectedRoute?.id || null}
|
||||
@@ -131,7 +131,7 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Documents */}
|
||||
<div className="col-span-12 lg:col-span-5 h-full overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<DocumentList
|
||||
documents={currentDocuments}
|
||||
routeCode={selectedRoute?.code || null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { refreshAccessToken } from '@/lib/api/refresh-token';
|
||||
|
||||
/**
|
||||
* 🔵 Next.js 내부 API - 인증 상태 확인 (PHP 백엔드 X)
|
||||
@@ -49,72 +50,48 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Only has refresh token - try to refresh
|
||||
if (refreshToken && !accessToken) {
|
||||
console.log('🔄 Access token missing, attempting refresh...');
|
||||
console.log('🔍 Refresh token exists:', refreshToken.substring(0, 20) + '...');
|
||||
console.log('🔍 Backend URL:', process.env.NEXT_PUBLIC_API_URL);
|
||||
console.log('🔄 [auth/check] Access token missing, attempting refresh...');
|
||||
|
||||
// Attempt token refresh
|
||||
try {
|
||||
const refreshResponse = 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,
|
||||
}),
|
||||
});
|
||||
// 공유 캐시를 사용하는 refreshAccessToken 함수 사용
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'auth/check');
|
||||
|
||||
console.log('🔍 Refresh API response status:', refreshResponse.status);
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [auth/check] Token refreshed successfully');
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
const data = await refreshResponse.json();
|
||||
// Set new tokens with Safari-compatible configuration
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Set new tokens with Safari-compatible configuration
|
||||
// Safari compatibility: Secure only in production (HTTPS)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const accessTokenCookie = [
|
||||
`access_token=${refreshResult.accessToken}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${refreshResult.expiresIn || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
'HttpOnly', // ✅ JavaScript cannot access
|
||||
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${refreshResult.refreshToken}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
'Max-Age=604800',
|
||||
].join('; ');
|
||||
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
'HttpOnly', // ✅ JavaScript cannot access
|
||||
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days (longer for refresh token)
|
||||
].join('; ');
|
||||
const response = NextResponse.json(
|
||||
{ authenticated: true, refreshed: true },
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
console.log('✅ Token auto-refreshed in auth check');
|
||||
response.headers.append('Set-Cookie', accessTokenCookie);
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ authenticated: true, refreshed: true },
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
response.headers.append('Set-Cookie', accessTokenCookie);
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
|
||||
return response;
|
||||
} else {
|
||||
const errorData = await refreshResponse.text();
|
||||
console.error('❌ Refresh API failed:', refreshResponse.status, errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Token refresh failed in auth check:', error);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Refresh failed - not authenticated
|
||||
console.log('⚠️ Returning 401 due to refresh failure');
|
||||
console.log('⚠️ [auth/check] Refresh failed, returning 401');
|
||||
return NextResponse.json(
|
||||
{ error: 'Token refresh failed' },
|
||||
{ status: 401 }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { refreshAccessToken } from '@/lib/api/refresh-token';
|
||||
|
||||
/**
|
||||
* 🔵 Next.js 내부 API - 토큰 갱신 프록시 (PHP 백엔드로 전달)
|
||||
@@ -43,24 +44,14 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Call PHP backend refresh API
|
||||
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,
|
||||
}),
|
||||
});
|
||||
// 공유 캐시를 사용하는 refreshAccessToken 함수 사용
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'api/auth/refresh');
|
||||
|
||||
if (!response.ok) {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (!refreshResult.success || !refreshResult.accessToken) {
|
||||
// Refresh token is invalid or expired
|
||||
console.warn('⚠️ Token refresh failed - user needs to re-login');
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
console.warn('⚠️ [api/auth/refresh] Token refresh failed - user needs to re-login');
|
||||
|
||||
// Clear all tokens
|
||||
const clearAccessToken = `access_token=; HttpOnly; ${isProduction ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`;
|
||||
@@ -79,30 +70,24 @@ export async function POST(request: NextRequest) {
|
||||
return failResponse;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Prepare response
|
||||
const responseData = {
|
||||
message: 'Token refreshed successfully',
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: data.expires_at,
|
||||
expires_in: refreshResult.expiresIn,
|
||||
};
|
||||
|
||||
// Set new HttpOnly cookies
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
`access_token=${refreshResult.accessToken}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
`Max-Age=${refreshResult.expiresIn || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
`refresh_token=${refreshResult.refreshToken}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
@@ -116,10 +101,10 @@ export async function POST(request: NextRequest) {
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
`Max-Age=${refreshResult.expiresIn || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Token refresh successful - New tokens stored');
|
||||
console.log('✅ [api/auth/refresh] Token refresh successful');
|
||||
|
||||
const successResponse = NextResponse.json(responseData, { status: 200 });
|
||||
|
||||
|
||||
@@ -437,15 +437,24 @@ html {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Sheet Content - Right side slide animation */
|
||||
[data-slot="sheet-content"][data-state="open"] {
|
||||
/* Sheet Content - Right side slide animation (기본값, right-0 클래스) */
|
||||
[data-slot="sheet-content"].right-0[data-state="open"] {
|
||||
animation: slideInFromRight 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="sheet-content"][data-state="closed"] {
|
||||
[data-slot="sheet-content"].right-0[data-state="closed"] {
|
||||
animation: slideOutToRight 200ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Sheet Content - Left side slide animation (left-0 클래스) */
|
||||
[data-slot="sheet-content"].left-0[data-state="open"] {
|
||||
animation: slideInFromLeft 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="sheet-content"].left-0[data-state="closed"] {
|
||||
animation: slideOutToLeft 200ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Sheet Overlay - Fade animation */
|
||||
[data-slot="sheet-overlay"][data-state="open"] {
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { BadDebtRecord, CollectionStatus } from './types';
|
||||
@@ -286,6 +288,7 @@ export async function getBadDebts(params?: {
|
||||
|
||||
return result.data.data.map(transformApiToFrontend);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] getBadDebts error:', error);
|
||||
return [];
|
||||
}
|
||||
@@ -319,6 +322,7 @@ export async function getBadDebtById(id: string): Promise<BadDebtRecord | null>
|
||||
|
||||
return transformApiToFrontend(result.data);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] getBadDebtById error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -352,6 +356,7 @@ export async function getBadDebtSummary(): Promise<BadDebtSummaryApiData | null>
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] getBadDebtSummary error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -396,6 +401,7 @@ export async function createBadDebt(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] createBadDebt error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -444,6 +450,7 @@ export async function updateBadDebt(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] updateBadDebt error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -479,6 +486,7 @@ export async function deleteBadDebt(id: string): Promise<{ success: boolean; err
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] deleteBadDebt error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -517,6 +525,7 @@ export async function toggleBadDebt(id: string): Promise<{ success: boolean; dat
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] toggleBadDebt error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -565,6 +574,7 @@ export async function addBadDebtMemo(
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] addBadDebtMemo error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -601,6 +611,7 @@ export async function deleteBadDebtMemo(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BadDebtActions] deleteBadDebtMemo error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { BankTransaction, TransactionKind } from './types';
|
||||
|
||||
@@ -155,6 +157,7 @@ export async function getBankTransactionList(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BankTransactionActions] getBankTransactionList error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -223,6 +226,7 @@ export async function getBankTransactionSummary(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BankTransactionActions] getBankTransactionSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -270,6 +274,7 @@ export async function getBankAccountOptions(): Promise<{
|
||||
data: result.data as BankAccountOption[],
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BankTransactionActions] getBankAccountOptions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { BillRecord, BillApiData, BillStatus } from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi } from './types';
|
||||
@@ -103,6 +105,7 @@ export async function getBills(params: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getBills] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -145,6 +148,7 @@ export async function getBill(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data as BillApiData),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getBill] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
@@ -191,6 +195,7 @@ export async function createBill(
|
||||
data: transformApiToFrontend(result.data as BillApiData),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createBill] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
@@ -238,6 +243,7 @@ export async function updateBill(
|
||||
data: transformApiToFrontend(result.data as BillApiData),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateBill] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
@@ -267,6 +273,7 @@ export async function deleteBill(id: string): Promise<{ success: boolean; error?
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteBill] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
@@ -305,6 +312,7 @@ export async function updateBillStatus(
|
||||
data: transformApiToFrontend(result.data as BillApiData),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateBillStatus] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
@@ -368,6 +376,7 @@ export async function getBillSummary(params: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getBillSummary] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
@@ -410,6 +419,7 @@ export async function getClients(): Promise<{
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getClients] Error:', error);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { CardTransaction } from './types';
|
||||
|
||||
@@ -158,6 +160,7 @@ export async function getCardTransactionList(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CardTransactionActions] getCardTransactionList error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -226,6 +229,7 @@ export async function getCardTransactionSummary(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CardTransactionActions] getCardTransactionSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -277,6 +281,7 @@ export async function bulkUpdateAccountCode(
|
||||
updatedCount: result.data?.updated_count || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CardTransactionActions] bulkUpdateAccountCode error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { NoteReceivableItem, DailyAccountItem, MatchStatus } from './types';
|
||||
@@ -116,6 +118,7 @@ export async function getNoteReceivables(params?: {
|
||||
data: items,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DailyReportActions] getNoteReceivables error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -173,6 +176,7 @@ export async function getDailyAccounts(params?: {
|
||||
data: items,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DailyReportActions] getDailyAccounts error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -254,6 +258,7 @@ export async function getDailyReportSummary(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DailyReportActions] getDailyReportSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -310,6 +315,7 @@ export async function exportDailyReportExcel(params?: {
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DailyReportActions] exportDailyReportExcel error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { DepositRecord, DepositType, DepositStatus } from './types';
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ExpectedExpenseRecord, TransactionType, PaymentStatus, ApprovalStatus } from './types';
|
||||
|
||||
@@ -188,6 +190,7 @@ export async function getExpectedExpenses(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] getExpectedExpenses error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -236,6 +239,7 @@ export async function getExpectedExpenseById(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] getExpectedExpenseById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -277,6 +281,7 @@ export async function createExpectedExpense(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] createExpectedExpense error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -319,6 +324,7 @@ export async function updateExpectedExpense(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] updateExpectedExpense error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -350,6 +356,7 @@ export async function deleteExpectedExpense(id: string): Promise<{ success: bool
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] deleteExpectedExpense error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -393,6 +400,7 @@ export async function deleteExpectedExpenses(ids: string[]): Promise<{
|
||||
deletedCount: result.data?.deleted_count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] deleteExpectedExpenses error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -440,6 +448,7 @@ export async function updateExpectedPaymentDate(
|
||||
updatedCount: result.data?.updated_count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] updateExpectedPaymentDate error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -498,6 +507,7 @@ export async function getExpectedExpenseSummary(params?: {
|
||||
data: result.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] getExpectedExpenseSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -542,6 +552,7 @@ export async function getClients(): Promise<{
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] getClients error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -585,6 +596,7 @@ export async function getBankAccounts(): Promise<{
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ExpectedExpenseActions] getBankAccounts error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { PurchaseRecord, PurchaseType } from './types';
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { VendorReceivables, CategoryType, MonthlyAmount, ReceivablesListResponse, MemoUpdateRequest } from './types';
|
||||
@@ -55,7 +57,6 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
|
||||
total: cat.amounts.total,
|
||||
},
|
||||
})),
|
||||
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ export async function getReceivablesList(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivablesActions] getReceivablesList error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -207,6 +209,7 @@ export async function getReceivablesSummary(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivablesActions] getReceivablesSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -262,6 +265,7 @@ export async function updateOverdueStatus(
|
||||
updatedCount: result.data?.updated_count || updates.length,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivablesActions] updateOverdueStatus error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -317,6 +321,7 @@ export async function updateMemos(
|
||||
updatedCount: result.data?.updated_count || memos.length,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivablesActions] updateMemos error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -383,6 +388,7 @@ export async function exportReceivablesExcel(params?: {
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivablesActions] exportReceivablesExcel error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from 'react';
|
||||
import { Download, FileText, Save, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Download, FileText, Save, Loader2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table';
|
||||
import {
|
||||
@@ -537,7 +538,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
{categoryData.amounts.values.map((amount, monthIndex) => (
|
||||
<TableCell
|
||||
key={`${vendor.id}-${category}-${monthIndex}`}
|
||||
className="text-right text-sm border-r border-gray-200"
|
||||
className={`text-right text-sm border-r border-gray-200 ${rowBgClass}`}
|
||||
>
|
||||
{formatAmount(amount)}
|
||||
</TableCell>
|
||||
@@ -552,20 +553,39 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
});
|
||||
|
||||
// 메모 행 추가 (마지막 행)
|
||||
const isMemoExpanded = expandedMemos.has(vendor.id);
|
||||
rows.push(
|
||||
<TableRow key={`${vendor.id}-memo`}>
|
||||
{/* 구분: 메모 */}
|
||||
{/* 구분: 메모 + 접기/펼치기 버튼 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
|
||||
메모
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>메모</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMemoExpand(vendor.id)}
|
||||
className="p-0.5 hover:bg-gray-200 rounded"
|
||||
>
|
||||
{isMemoExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 메모 입력 - 모든 월 컬럼 + 합계 컬럼 병합 */}
|
||||
<TableCell colSpan={monthCount + 1} className="p-1">
|
||||
<Input
|
||||
<TableCell colSpan={monthCount + 1} className={`p-1 ${rowBgClass}`}>
|
||||
<Textarea
|
||||
value={vendor.memo}
|
||||
onChange={(e) => handleMemoChange(vendor.id, e.target.value)}
|
||||
placeholder="거래처 메모를 입력하세요..."
|
||||
className="w-full h-8 text-sm"
|
||||
className="w-full text-sm resize-none transition-all !min-h-0 !py-1"
|
||||
style={{
|
||||
height: isMemoExpanded ? '72px' : '24px',
|
||||
lineHeight: '24px',
|
||||
overflowY: isMemoExpanded ? 'auto' : 'hidden',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -47,7 +47,6 @@ export interface VendorReceivables {
|
||||
carryForwardBalance: number; // 이월잔액
|
||||
monthLabels: string[]; // 동적 월 레이블 (ex: ['25.02', '25.03', ...])
|
||||
categories: CategoryData[];
|
||||
memo?: string; // 거래처별 메모 (단일 텍스트)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
SalesRecord,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { VendorLedgerItem, VendorLedgerDetail, VendorLedgerSummary, TransactionEntry } from './types';
|
||||
@@ -224,6 +226,7 @@ export async function getVendorLedgerList(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[VendorLedgerActions] getVendorLedgerList error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -287,6 +290,7 @@ export async function getVendorLedgerSummary(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[VendorLedgerActions] getVendorLedgerSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -350,6 +354,7 @@ export async function getVendorLedgerDetail(clientId: string, params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[VendorLedgerActions] getVendorLedgerDetail error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -410,6 +415,7 @@ export async function exportVendorLedgerExcel(params?: {
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[VendorLedgerActions] exportVendorLedgerExcel error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -468,6 +474,7 @@ export async function exportVendorLedgerDetailPdf(clientId: string, params?: {
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[VendorLedgerActions] exportVendorLedgerDetailPdf error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
Vendor,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { WithdrawalRecord, WithdrawalType } from './types';
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
|
||||
|
||||
@@ -222,6 +224,7 @@ export async function getInbox(params?: {
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ApprovalBoxActions] getInbox error:', error);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
@@ -250,6 +253,7 @@ export async function getInboxSummary(): Promise<InboxSummary | null> {
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ApprovalBoxActions] getInboxSummary error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -287,6 +291,7 @@ export async function approveDocument(id: string, comment?: string): Promise<{ s
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ApprovalBoxActions] approveDocument error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -334,6 +339,7 @@ export async function rejectDocument(id: string, comment: string): Promise<{ suc
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ApprovalBoxActions] rejectDocument error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
@@ -183,6 +185,7 @@ export async function uploadFiles(files: File[]): Promise<{
|
||||
|
||||
return { success: true, data: uploadedFiles };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] uploadFiles error:', error);
|
||||
return { success: false, error: '파일 업로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -234,6 +237,7 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
finalDifference: result.data.final_difference,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getExpenseEstimateItems error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -274,6 +278,7 @@ export async function getEmployees(search?: string): Promise<ApprovalPerson[]> {
|
||||
|
||||
return result.data.data.map(transformEmployee);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getEmployees error:', error);
|
||||
return [];
|
||||
}
|
||||
@@ -354,6 +359,7 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] createApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -396,6 +402,7 @@ export async function submitApproval(id: number): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] submitApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -433,6 +440,7 @@ export async function createAndSubmitApproval(formData: DocumentFormData): Promi
|
||||
data: createResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] createAndSubmitApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -480,6 +488,7 @@ export async function getApprovalById(id: number): Promise<{
|
||||
|
||||
return { success: true, data: formDataResult };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getApprovalById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -558,6 +567,7 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] updateApproval error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -592,6 +602,7 @@ export async function updateAndSubmitApproval(id: number, formData: DocumentForm
|
||||
data: updateResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] updateAndSubmitApproval error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -630,6 +641,7 @@ export async function deleteApproval(id: number): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] deleteApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { DraftRecord, DocumentStatus, Approver } from './types';
|
||||
|
||||
@@ -225,6 +227,7 @@ export async function getDrafts(params?: {
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] getDrafts error:', error);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
@@ -253,6 +256,7 @@ export async function getDraftsSummary(): Promise<DraftsSummary | null> {
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] getDraftsSummary error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -281,6 +285,7 @@ export async function getDraftById(id: string): Promise<DraftRecord | null> {
|
||||
|
||||
return transformApiToFrontend(result.data);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] getDraftById error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -315,6 +320,7 @@ export async function deleteDraft(id: string): Promise<{ success: boolean; error
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] deleteDraft error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -379,6 +385,7 @@ export async function submitDraft(id: string): Promise<{ success: boolean; error
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] submitDraft error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -443,6 +450,7 @@ export async function cancelDraft(id: string): Promise<{ success: boolean; error
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] cancelDraft error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
|
||||
|
||||
@@ -185,6 +187,7 @@ export async function getReferences(params?: {
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReferenceBoxActions] getReferences error:', error);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
@@ -206,6 +209,7 @@ export async function getReferenceSummary(): Promise<{ all: number; read: number
|
||||
unread: unreadResult.total,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReferenceBoxActions] getReferenceSummary error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -242,6 +246,7 @@ export async function markAsRead(id: string): Promise<{ success: boolean; error?
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReferenceBoxActions] markAsRead error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -281,6 +286,7 @@ export async function markAsUnread(id: string): Promise<{ success: boolean; erro
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReferenceBoxActions] markAsUnread error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
@@ -141,6 +143,7 @@ export async function checkIn(
|
||||
error: result.message || '출근 기록에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[checkIn] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -194,6 +197,7 @@ export async function checkOut(
|
||||
error: result.message || '퇴근 기록에 실패했습니다.',
|
||||
};
|
||||
} catch (err) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[checkOut] Error:', err);
|
||||
return {
|
||||
success: false,
|
||||
@@ -249,6 +253,7 @@ export async function getTodayAttendance(): Promise<{
|
||||
error: result.message || '근태 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getTodayAttendance] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Board, BoardApiData, BoardFormData } from './types';
|
||||
|
||||
@@ -133,6 +135,7 @@ export async function getBoards(filters?: {
|
||||
const boards = result.data.map(transformApiToFrontend);
|
||||
return { success: true, data: boards };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getBoards error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -181,6 +184,7 @@ export async function getTenantBoards(filters?: {
|
||||
const boards = result.data.map(transformApiToFrontend);
|
||||
return { success: true, data: boards };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getTenantBoards error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -220,6 +224,7 @@ export async function getBoardByCode(code: string): Promise<{ success: boolean;
|
||||
|
||||
return { success: true, data: transformApiToFrontend(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getBoardByCode error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -259,6 +264,7 @@ export async function getBoardById(id: string): Promise<{ success: boolean; data
|
||||
|
||||
return { success: true, data: transformApiToFrontend(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getBoardById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -307,6 +313,7 @@ export async function createBoard(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] createBoard error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -356,6 +363,7 @@ export async function updateBoard(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] updateBoard error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -397,6 +405,7 @@ export async function deleteBoard(id: string): Promise<{ success: boolean; error
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] deleteBoard error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -424,6 +433,7 @@ export async function deleteBoardsBulk(ids: string[]): Promise<{ success: boolea
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] deleteBoardsBulk error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
PostApiData,
|
||||
@@ -60,6 +62,7 @@ export async function getDynamicBoardPosts(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] getDynamicBoardPosts error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -96,6 +99,7 @@ export async function getDynamicBoardPost(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] getDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -138,6 +142,7 @@ export async function createDynamicBoardPost(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] createDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -181,6 +186,7 @@ export async function updateDynamicBoardPost(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] updateDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -216,6 +222,7 @@ export async function deleteDynamicBoardPost(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] deleteDynamicBoardPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -254,6 +261,7 @@ export async function getDynamicBoardComments(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] getDynamicBoardComments error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -291,6 +299,7 @@ export async function createDynamicBoardComment(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] createDynamicBoardComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -329,6 +338,7 @@ export async function updateDynamicBoardComment(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] updateDynamicBoardComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -365,6 +375,7 @@ export async function deleteDynamicBoardComment(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[DynamicBoardActions] deleteDynamicBoardComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
PostApiData,
|
||||
@@ -101,6 +103,7 @@ export async function getPosts(
|
||||
|
||||
return { success: true, data: result.data, posts };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getPosts error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -153,6 +156,7 @@ export async function getMyPosts(
|
||||
|
||||
return { success: true, data: result.data, posts };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getMyPosts error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -189,6 +193,7 @@ export async function getPost(
|
||||
|
||||
return { success: true, data: transformApiToPost(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] getPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -231,6 +236,7 @@ export async function createPost(
|
||||
|
||||
return { success: true, data: transformApiToPost(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] createPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -274,6 +280,7 @@ export async function updatePost(
|
||||
|
||||
return { success: true, data: transformApiToPost(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] updatePost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -309,6 +316,7 @@ export async function deletePost(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[BoardActions] deletePost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
605
src/components/business/CEODashboard/CEODashboard.tsx
Normal file
605
src/components/business/CEODashboard/CEODashboard.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Loader2, LayoutDashboard, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import {
|
||||
TodayIssueSection,
|
||||
DailyReportSection,
|
||||
MonthlyExpenseSection,
|
||||
CardManagementSection,
|
||||
EntertainmentSection,
|
||||
WelfareSection,
|
||||
ReceivableSection,
|
||||
DebtCollectionSection,
|
||||
VatSection,
|
||||
CalendarSection,
|
||||
} from './sections';
|
||||
import type { CEODashboardData, CalendarScheduleItem, DashboardSettings } from './types';
|
||||
import { DEFAULT_DASHBOARD_SETTINGS } from './types';
|
||||
import { ScheduleDetailModal } from './modals';
|
||||
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
||||
|
||||
// 목데이터
|
||||
const mockData: CEODashboardData = {
|
||||
todayIssue: [
|
||||
{ id: '1', label: '수주', count: 3, path: '/sales/order-management-sales', isHighlighted: false },
|
||||
{ id: '2', label: '채권 추심', count: 3, path: '/accounting/bad-debt-collection', isHighlighted: false },
|
||||
{ id: '3', label: '안전 재고', count: 3, path: '/material/stock-status', isHighlighted: true },
|
||||
{ id: '4', label: '세금 신고', count: '부가세 신고 D-15', path: '/accounting/tax', isHighlighted: false },
|
||||
{ id: '5', label: '신규 업체 등록', count: 3, path: '/accounting/vendors', isHighlighted: false },
|
||||
{ id: '6', label: '연차', count: 3, path: '/hr/vacation-management', isHighlighted: false },
|
||||
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', isHighlighted: false },
|
||||
{ id: '8', label: '결재 요청', count: 3, path: '/approval/inbox', isHighlighted: false },
|
||||
],
|
||||
dailyReport: {
|
||||
date: '2026년 1월 5일 월요일',
|
||||
cards: [
|
||||
{ id: 'dr1', label: '현금성 자산 합계', amount: 3050000000 },
|
||||
{ id: 'dr2', label: '외국환(USD) 합계', amount: 11123000, currency: 'USD' },
|
||||
{ id: 'dr3', label: '입금 합계', amount: 1020000000 },
|
||||
{ id: 'dr4', label: '출금 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'dr-cp1',
|
||||
type: 'success',
|
||||
message: '어제 3.5억원 출금했습니다. 최근 7일 평균 대비 2배 이상으로 점검이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '3.5억원 출금', color: 'red' },
|
||||
{ text: '점검이 필요', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dr-cp2',
|
||||
type: 'success',
|
||||
message: '어제 10.2억원이 입금되었습니다. 대한건설 선수금 입금이 주요 원인입니다.',
|
||||
highlights: [
|
||||
{ text: '10.2억원', color: 'green' },
|
||||
{ text: '입금', color: 'green' },
|
||||
{ text: '대한건설 선수금 입금', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dr-cp3',
|
||||
type: 'success',
|
||||
message: '총 현금성 자산이 300.2억원입니다. 월 운영비용 대비 18개월분이 확보되어 안정적입니다.',
|
||||
highlights: [
|
||||
{ text: '18개월분', color: 'blue' },
|
||||
{ text: '안정적', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
monthlyExpense: {
|
||||
cards: [
|
||||
{ id: 'me1', label: '매입', amount: 3050000000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me2', label: '카드', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me3', label: '발행어음', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me4', label: '총 예상 지출 합계', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'me-cp1',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.',
|
||||
highlights: [
|
||||
{ text: '전월 대비 15% 증가', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'me-cp2',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '예산을 12% 초과', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'me-cp3',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 전월 대비 8% 감소했습니다. {계정과목명} 비용이 줄었습니다.',
|
||||
highlights: [
|
||||
{ text: '전월 대비 8% 감소', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cardManagement: {
|
||||
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
|
||||
cards: [
|
||||
{ id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' },
|
||||
{ id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'cm3', label: '법인세 예상 가산', amount: 3123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'cm4', label: '종합세 예상 가산', amount: 3123000, previousLabel: '추가 사안 +10.5%' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'cm-cp1',
|
||||
type: 'success',
|
||||
message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.',
|
||||
highlights: [
|
||||
{ text: '850만원', color: 'red' },
|
||||
{ text: '가지급금', color: 'red' },
|
||||
{ text: '연 4.6% 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp2',
|
||||
type: 'success',
|
||||
message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.',
|
||||
highlights: [
|
||||
{ text: '연 약 1,400만원의 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp3',
|
||||
type: 'success',
|
||||
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
|
||||
highlights: [
|
||||
{ text: '불인정 항목 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp4',
|
||||
type: 'success',
|
||||
message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.',
|
||||
highlights: [
|
||||
{ text: '주말 카드 사용 100만원 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
entertainment: {
|
||||
cards: [
|
||||
{ id: 'et1', label: '매출', amount: 30530000000 },
|
||||
{ id: 'et2', label: '{1사분기} 접대비 총 한도', amount: 40123000 },
|
||||
{ id: 'et3', label: '{1사분기} 접대비 잔여한도', amount: 30123000 },
|
||||
{ id: 'et4', label: '{1사분기} 접대비 사용금액', amount: 10000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'et-cp1',
|
||||
type: 'success',
|
||||
message: '{1사분기} 접대비 사용 1,000만원 / 한도 4,012만원 (75%). 여유 있게 운영 중입니다.',
|
||||
highlights: [
|
||||
{ text: '1,000만원', color: 'green' },
|
||||
{ text: '4,012만원 (75%)', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp2',
|
||||
type: 'success',
|
||||
message: '접대비 한도 85% 도달. 잔여 한도 600만원입니다. 사용 계획을 점검해 주세요.',
|
||||
highlights: [
|
||||
{ text: '잔여 한도 600만원', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp3',
|
||||
type: 'error',
|
||||
message: '접대비 한도 초과 320만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.',
|
||||
highlights: [
|
||||
{ text: '320만원 발생', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp4',
|
||||
type: 'error',
|
||||
message: '접대비 사용 중 3건(45만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.',
|
||||
highlights: [
|
||||
{ text: '3건(45만원)', color: 'red' },
|
||||
{ text: '거래처 정보가 누락', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
welfare: {
|
||||
cards: [
|
||||
{ id: 'wf1', label: '당해년도 복리후생비 한도', amount: 30123000 },
|
||||
{ id: 'wf2', label: '{1사분기} 복리후생비 총 한도', amount: 10123000 },
|
||||
{ id: 'wf3', label: '{1사분기} 복리후생비 잔여한도', amount: 5123000 },
|
||||
{ id: 'wf4', label: '{1사분기} 복리후생비 사용금액', amount: 5123000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'wf-cp1',
|
||||
type: 'success',
|
||||
message: '1인당 월 복리후생비 20만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.',
|
||||
highlights: [
|
||||
{ text: '1인당 월 복리후생비 20만원', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wf-cp2',
|
||||
type: 'error',
|
||||
message: '식대가 월 25만원으로 비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.',
|
||||
highlights: [
|
||||
{ text: '식대가 월 25만원으로', color: 'red' },
|
||||
{ text: '초과', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
receivable: {
|
||||
cards: [
|
||||
{
|
||||
id: 'rv1',
|
||||
label: '누적 미수금',
|
||||
amount: 30123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 60123000 },
|
||||
{ label: '입금', value: 30000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv2',
|
||||
label: '당월 미수금',
|
||||
amount: 10123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 60123000 },
|
||||
{ label: '입금', value: 30000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv3',
|
||||
label: '회사명',
|
||||
amount: 3123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 6123000 },
|
||||
{ label: '입금', value: 3000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv4',
|
||||
label: '회사명',
|
||||
amount: 2123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 6123000 },
|
||||
{ label: '입금', value: 3000000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'rv-cp1',
|
||||
type: 'success',
|
||||
message: '90일 이상 장기 미수금 3건(2,500만원) 발생. 회수 조치가 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '90일 이상 장기 미수금 3건(2,500만원) 발생', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv-cp2',
|
||||
type: 'success',
|
||||
message: '(주)대한전자 미수금 1,500만원으로 전체의 35%를 차지합니다. 리스크 분산이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '(주)대한전자 미수금 1,500만원으로 전체의 35%를', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
debtCollection: {
|
||||
cards: [
|
||||
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
|
||||
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
|
||||
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
|
||||
{ id: 'dc4', label: '회수완료', amount: 280000000, subLabel: '10건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'dc-cp1',
|
||||
type: 'success',
|
||||
message: '(주)대한전자 건 지급명령 신청 완료. 법원 결정까지 약 2주 소요 예정입니다.',
|
||||
highlights: [{ text: '(주)대한전자 건 지급명령 신청 완료.', color: 'red' }],
|
||||
},
|
||||
{
|
||||
id: 'dc-cp2',
|
||||
type: 'success',
|
||||
message: '(주)삼성테크 건 회수 불가 판정. 대손 처리 검토가 필요합니다.',
|
||||
highlights: [{ text: '(주)삼성테크 건 회수 불가 판정.', color: 'red' }],
|
||||
},
|
||||
],
|
||||
detailButtonPath: '/accounting/bad-debt-collection',
|
||||
},
|
||||
vat: {
|
||||
cards: [
|
||||
{ id: 'vat1', label: '매출세액', amount: 3050000000 },
|
||||
{ id: 'vat2', label: '매입세액', amount: 2050000000 },
|
||||
{ id: 'vat3', label: '예상 납부세액', amount: 110000000 },
|
||||
{ id: 'vat4', label: '세금계산서 미발행', amount: 3, unit: '건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'vat-cp1',
|
||||
type: 'success',
|
||||
message: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다. 설비투자에 따른 매입세액 증가가 주요 원인입니다.',
|
||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다.', color: 'red' }],
|
||||
},
|
||||
{
|
||||
id: 'vat-cp2',
|
||||
type: 'success',
|
||||
message: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다. 전기 대비 12.9% 증가했으며, 이는 매출 증가에 따른 정상적인 증가로 판단됩니다.',
|
||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다.', color: 'red' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
calendarSchedules: [
|
||||
{
|
||||
id: 'sch1',
|
||||
title: '제목',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-04',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
type: 'schedule',
|
||||
department: '부서명',
|
||||
},
|
||||
{
|
||||
id: 'sch2',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
type: 'schedule',
|
||||
personName: '홍길동',
|
||||
},
|
||||
{
|
||||
id: 'sch3',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
type: 'order',
|
||||
department: '부서명',
|
||||
},
|
||||
{
|
||||
id: 'sch4',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
startTime: '12:35',
|
||||
type: 'construction',
|
||||
personName: '홍길동',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function CEODashboard() {
|
||||
const [isLoading] = useState(false);
|
||||
const [data] = useState<CEODashboardData>(mockData);
|
||||
|
||||
// 일정 상세 모달 상태
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<CalendarScheduleItem | null>(null);
|
||||
|
||||
// 항목 설정 모달 상태
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(() => {
|
||||
// localStorage에서 설정 불러오기
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('ceo-dashboard-settings');
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
return DEFAULT_DASHBOARD_SETTINGS;
|
||||
}
|
||||
}
|
||||
}
|
||||
return DEFAULT_DASHBOARD_SETTINGS;
|
||||
});
|
||||
|
||||
// 항목 설정 클릭
|
||||
const handleSettingClick = useCallback(() => {
|
||||
setIsSettingsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 항목 설정 저장
|
||||
const handleSettingsSave = useCallback((settings: DashboardSettings) => {
|
||||
setDashboardSettings(settings);
|
||||
// localStorage에 설정 저장
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('ceo-dashboard-settings', JSON.stringify(settings));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 항목 설정 모달 닫기
|
||||
const handleSettingsModalClose = useCallback(() => {
|
||||
setIsSettingsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
// 일일 일보 클릭
|
||||
const handleDailyReportClick = useCallback(() => {
|
||||
// TODO: 일일 일보 상세 팝업 열기
|
||||
console.log('일일 일보 클릭');
|
||||
}, []);
|
||||
|
||||
// 당월 예상 지출 클릭
|
||||
const handleMonthlyExpenseClick = useCallback(() => {
|
||||
// TODO: 당월 예상 지출 상세 팝업 열기
|
||||
console.log('당월 예상 지출 클릭');
|
||||
}, []);
|
||||
|
||||
// 접대비 클릭
|
||||
const handleEntertainmentClick = useCallback(() => {
|
||||
// TODO: 접대비 상세 팝업 열기
|
||||
console.log('접대비 클릭');
|
||||
}, []);
|
||||
|
||||
// 부가세 클릭
|
||||
const handleVatClick = useCallback(() => {
|
||||
// TODO: 부가세 상세 팝업 열기
|
||||
console.log('부가세 클릭');
|
||||
}, []);
|
||||
|
||||
// 캘린더 일정 클릭 (기존 일정 수정)
|
||||
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
|
||||
setSelectedSchedule(schedule);
|
||||
setIsScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 캘린더 일정 등록 (새 일정)
|
||||
const handleScheduleEdit = useCallback((schedule: CalendarScheduleItem) => {
|
||||
setSelectedSchedule(schedule);
|
||||
setIsScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 일정 모달 닫기
|
||||
const handleScheduleModalClose = useCallback(() => {
|
||||
setIsScheduleModalOpen(false);
|
||||
setSelectedSchedule(null);
|
||||
}, []);
|
||||
|
||||
// 일정 저장
|
||||
const handleScheduleSave = useCallback((formData: {
|
||||
title: string;
|
||||
department: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isAllDay: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
color: string;
|
||||
content: string;
|
||||
}) => {
|
||||
console.log('일정 저장:', formData);
|
||||
// TODO: API 호출하여 일정 저장
|
||||
setIsScheduleModalOpen(false);
|
||||
setSelectedSchedule(null);
|
||||
}, []);
|
||||
|
||||
// 일정 삭제
|
||||
const handleScheduleDelete = useCallback((id: string) => {
|
||||
console.log('일정 삭제:', id);
|
||||
// TODO: API 호출하여 일정 삭제
|
||||
setIsScheduleModalOpen(false);
|
||||
setSelectedSchedule(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="대시보드"
|
||||
description="전체 현황을 조회합니다."
|
||||
icon={LayoutDashboard}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="대시보드"
|
||||
description="전체 현황을 조회합니다."
|
||||
icon={LayoutDashboard}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSettingClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
항목 설정
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 오늘의 이슈 */}
|
||||
{dashboardSettings.todayIssue.enabled && (
|
||||
<TodayIssueSection
|
||||
items={data.todayIssue}
|
||||
itemSettings={dashboardSettings.todayIssue.items}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 일일 일보 */}
|
||||
{dashboardSettings.dailyReport && (
|
||||
<DailyReportSection
|
||||
data={data.dailyReport}
|
||||
onClick={handleDailyReportClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 당월 예상 지출 내역 */}
|
||||
{dashboardSettings.monthlyExpense && (
|
||||
<MonthlyExpenseSection
|
||||
data={data.monthlyExpense}
|
||||
onClick={handleMonthlyExpenseClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 카드/가지급금 관리 */}
|
||||
{dashboardSettings.cardManagement && (
|
||||
<CardManagementSection data={data.cardManagement} />
|
||||
)}
|
||||
|
||||
{/* 접대비 현황 */}
|
||||
{dashboardSettings.entertainment.enabled && (
|
||||
<EntertainmentSection
|
||||
data={data.entertainment}
|
||||
onClick={handleEntertainmentClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 복리후생비 현황 */}
|
||||
{dashboardSettings.welfare.enabled && (
|
||||
<WelfareSection data={data.welfare} />
|
||||
)}
|
||||
|
||||
{/* 미수금 현황 */}
|
||||
{dashboardSettings.receivable.enabled && (
|
||||
<ReceivableSection data={data.receivable} />
|
||||
)}
|
||||
|
||||
{/* 채권추심 현황 */}
|
||||
{dashboardSettings.debtCollection && (
|
||||
<DebtCollectionSection data={data.debtCollection} />
|
||||
)}
|
||||
|
||||
{/* 부가세 현황 */}
|
||||
{dashboardSettings.vat && (
|
||||
<VatSection data={data.vat} onClick={handleVatClick} />
|
||||
)}
|
||||
|
||||
{/* 캘린더 */}
|
||||
{dashboardSettings.calendar && (
|
||||
<CalendarSection
|
||||
schedules={data.calendarSchedules}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
onScheduleEdit={handleScheduleEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 일정 상세 모달 */}
|
||||
<ScheduleDetailModal
|
||||
isOpen={isScheduleModalOpen}
|
||||
onClose={handleScheduleModalClose}
|
||||
schedule={selectedSchedule}
|
||||
onSave={handleScheduleSave}
|
||||
onDelete={handleScheduleDelete}
|
||||
/>
|
||||
|
||||
{/* 항목 설정 모달 */}
|
||||
<DashboardSettingsDialog
|
||||
isOpen={isSettingsModalOpen}
|
||||
onClose={handleSettingsModalClose}
|
||||
settings={dashboardSettings}
|
||||
onSave={handleSettingsSave}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
312
src/components/business/CEODashboard/components.tsx
Normal file
312
src/components/business/CEODashboard/components.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import { Check, AlertTriangle, Info, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CheckPoint, CheckPointType, AmountCard, HighlightColor } from './types';
|
||||
|
||||
/**
|
||||
* 금액 포맷 함수
|
||||
*/
|
||||
export const formatAmount = (amount: number, showUnit = true): string => {
|
||||
const formatted = new Intl.NumberFormat('ko-KR').format(amount);
|
||||
return showUnit ? formatted + '원' : formatted;
|
||||
};
|
||||
|
||||
/**
|
||||
* 억 단위 포맷 함수
|
||||
*/
|
||||
export const formatBillion = (amount: number): string => {
|
||||
const billion = amount / 100000000;
|
||||
if (billion >= 1) {
|
||||
return billion.toFixed(1) + '억원';
|
||||
}
|
||||
return formatAmount(amount);
|
||||
};
|
||||
|
||||
/**
|
||||
* USD 달러 포맷 함수
|
||||
*/
|
||||
export const formatUSD = (amount: number): string => {
|
||||
return '$ ' + new Intl.NumberFormat('en-US').format(amount);
|
||||
};
|
||||
|
||||
/**
|
||||
* 하이라이트 색상 클래스 반환
|
||||
*/
|
||||
export const getHighlightColorClass = (color: HighlightColor): string => {
|
||||
switch (color) {
|
||||
case 'red':
|
||||
return 'text-red-600';
|
||||
case 'green':
|
||||
return 'text-green-600';
|
||||
case 'blue':
|
||||
return 'text-blue-600';
|
||||
default:
|
||||
return 'text-red-600';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 체크포인트 아이콘
|
||||
*/
|
||||
export const CheckPointIcon = ({ type }: { type: CheckPointType }) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <Check className="h-4 w-4 text-green-600 flex-shrink-0" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-600 flex-shrink-0" />;
|
||||
case 'info':
|
||||
default:
|
||||
return <Info className="h-4 w-4 text-blue-600 flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 메시지 내 하이라이트 텍스트를 찾아서 색상 적용하는 함수
|
||||
*/
|
||||
const renderMessageWithHighlights = (
|
||||
message: string,
|
||||
highlights?: { text: string; color: HighlightColor }[]
|
||||
): React.ReactNode => {
|
||||
if (!highlights || highlights.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// 하이라이트를 적용할 패턴들을 정규식으로 만듦
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remainingText = message;
|
||||
let keyIndex = 0;
|
||||
|
||||
// 메시지에서 하이라이트 텍스트를 찾아 분리
|
||||
highlights.forEach((highlight) => {
|
||||
const index = remainingText.indexOf(highlight.text);
|
||||
if (index !== -1) {
|
||||
// 하이라이트 앞 텍스트
|
||||
if (index > 0) {
|
||||
parts.push(remainingText.substring(0, index));
|
||||
}
|
||||
// 하이라이트 텍스트 (색상 적용)
|
||||
parts.push(
|
||||
<span key={keyIndex++} className={cn('font-medium', getHighlightColorClass(highlight.color))}>
|
||||
{highlight.text}
|
||||
</span>
|
||||
);
|
||||
remainingText = remainingText.substring(index + highlight.text.length);
|
||||
}
|
||||
});
|
||||
|
||||
// 남은 텍스트 추가
|
||||
if (remainingText) {
|
||||
parts.push(remainingText);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : message;
|
||||
};
|
||||
|
||||
/**
|
||||
* 체크포인트 아이템 컴포넌트
|
||||
*/
|
||||
export const CheckPointItem = ({ checkpoint }: { checkpoint: CheckPoint }) => {
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-1">
|
||||
<div className="mt-0.5">
|
||||
<CheckPointIcon type={checkpoint.type} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{checkpoint.highlights && checkpoint.highlights.length > 0 ? (
|
||||
renderMessageWithHighlights(checkpoint.message, checkpoint.highlights)
|
||||
) : (
|
||||
<>
|
||||
{checkpoint.message}
|
||||
{checkpoint.highlight && (
|
||||
<span className="text-red-600 font-medium"> {checkpoint.highlight}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 섹션 타이틀 컴포넌트
|
||||
*/
|
||||
export const SectionTitle = ({
|
||||
title,
|
||||
badge,
|
||||
actionButton,
|
||||
}: {
|
||||
title: string;
|
||||
badge?: 'warning' | 'success' | 'info' | 'error';
|
||||
actionButton?: { label: string; onClick: () => void };
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
badge === 'warning' ? 'bg-amber-500' :
|
||||
badge === 'success' ? 'bg-green-500' :
|
||||
badge === 'info' ? 'bg-blue-500' : 'bg-red-500'
|
||||
)} />
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
{actionButton && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={actionButton.onClick}
|
||||
>
|
||||
{actionButton.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 금액 카드 컴포넌트
|
||||
*/
|
||||
export const AmountCardItem = ({
|
||||
card,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
card: AmountCard;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
// 금액 포맷 함수 (통화에 따라 분기)
|
||||
const formatCardAmount = (amount: number): string => {
|
||||
if (card.unit === '건') {
|
||||
return `${amount}건`;
|
||||
}
|
||||
if (card.currency === 'USD') {
|
||||
return formatUSD(amount);
|
||||
}
|
||||
return formatBillion(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
onClick && 'cursor-pointer hover:shadow-md transition-shadow',
|
||||
card.isHighlighted && 'border-red-300 bg-red-50',
|
||||
className
|
||||
)}>
|
||||
<CardContent
|
||||
className="p-4 md:p-6"
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className={cn(
|
||||
"text-sm font-medium mb-2",
|
||||
card.isHighlighted ? 'text-red-600' : 'text-muted-foreground'
|
||||
)}>
|
||||
{card.label}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl md:text-3xl font-bold",
|
||||
card.isHighlighted && 'text-red-600'
|
||||
)}>
|
||||
{formatCardAmount(card.amount)}
|
||||
</p>
|
||||
{/* subItems 배열이 있는 경우 (매출, 입금 등 다중 서브 정보) */}
|
||||
{card.subItems && card.subItems.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5 text-xs text-muted-foreground">
|
||||
{card.subItems.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between">
|
||||
<span>{item.label}</span>
|
||||
<span>{typeof item.value === 'number' ? formatAmount(item.value, false) : item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 기존 단일 서브 정보 */}
|
||||
{!card.subItems && (card.subAmount !== undefined || card.previousAmount !== undefined || card.subLabel || card.previousLabel) && (
|
||||
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||
{card.subAmount !== undefined && card.subLabel && (
|
||||
<span>{card.subLabel}: {card.unit === '건' ? `${card.subAmount}건` : formatAmount(card.subAmount)}</span>
|
||||
)}
|
||||
{card.previousLabel && (
|
||||
<span>{card.previousLabel}</span>
|
||||
)}
|
||||
{card.subLabel && card.subAmount === undefined && !card.previousLabel && (
|
||||
<span>{card.subLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 이슈 카드 그리드 아이템 (StatCards 스타일)
|
||||
*/
|
||||
export const IssueCardItem = ({
|
||||
label,
|
||||
count,
|
||||
subLabel,
|
||||
isHighlighted,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
count: number | string;
|
||||
subLabel?: string;
|
||||
isHighlighted?: boolean;
|
||||
onClick?: () => void;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer hover:shadow-md transition-shadow',
|
||||
isHighlighted && 'bg-red-500 border-red-500 hover:bg-red-600'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={cn(
|
||||
"text-sm font-medium",
|
||||
isHighlighted ? 'text-white' : 'text-muted-foreground'
|
||||
)}>
|
||||
{label}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"mt-2",
|
||||
typeof count === 'number'
|
||||
? "text-2xl md:text-3xl font-bold"
|
||||
: "text-xl md:text-2xl font-medium",
|
||||
isHighlighted ? 'text-white' : 'text-foreground'
|
||||
)}>
|
||||
{typeof count === 'number' ? `${count}건` : count}
|
||||
</p>
|
||||
{subLabel && (
|
||||
<p className={cn(
|
||||
"text-sm font-medium mt-1",
|
||||
isHighlighted ? 'text-white/90' : 'text-muted-foreground'
|
||||
)}>
|
||||
{subLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-10 h-10 md:w-12 md:h-12 opacity-15",
|
||||
isHighlighted ? 'text-white' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,713 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
DashboardSettings,
|
||||
TodayIssueSettings,
|
||||
EntertainmentLimitType,
|
||||
CompanyType,
|
||||
WelfareLimitType,
|
||||
WelfareCalculationType,
|
||||
} from '../types';
|
||||
import { DEFAULT_DASHBOARD_SETTINGS } from '../types';
|
||||
|
||||
// 오늘의 이슈 항목 라벨
|
||||
const TODAY_ISSUE_LABELS: Record<keyof TodayIssueSettings, string> = {
|
||||
orders: '수주',
|
||||
debtCollection: '채권 추심',
|
||||
safetyStock: '안전 재고',
|
||||
taxReport: '세금 신고',
|
||||
newVendor: '신규 업체 등록',
|
||||
annualLeave: '연차',
|
||||
lateness: '지각',
|
||||
absence: '결근',
|
||||
purchase: '발주',
|
||||
approvalRequest: '결재 요청',
|
||||
};
|
||||
|
||||
interface DashboardSettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: DashboardSettings;
|
||||
onSave: (settings: DashboardSettings) => void;
|
||||
}
|
||||
|
||||
export function DashboardSettingsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
settings,
|
||||
onSave,
|
||||
}: DashboardSettingsDialogProps) {
|
||||
const [localSettings, setLocalSettings] = useState<DashboardSettings>(settings);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
entertainment: false,
|
||||
welfare: false,
|
||||
receivable: false,
|
||||
companyTypeInfo: false,
|
||||
});
|
||||
|
||||
// settings가 변경될 때 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings);
|
||||
}, [settings]);
|
||||
|
||||
// 섹션 펼침/접힘 토글
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 오늘의 이슈 전체 토글
|
||||
const handleTodayIssueToggle = useCallback((enabled: boolean) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
todayIssue: {
|
||||
...prev.todayIssue,
|
||||
enabled,
|
||||
// 전체 OFF 시 개별 항목도 모두 OFF
|
||||
items: enabled
|
||||
? prev.todayIssue.items
|
||||
: Object.keys(prev.todayIssue.items).reduce(
|
||||
(acc, key) => ({ ...acc, [key]: false }),
|
||||
{} as TodayIssueSettings
|
||||
),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 오늘의 이슈 개별 항목 토글
|
||||
const handleTodayIssueItemToggle = useCallback(
|
||||
(key: keyof TodayIssueSettings, enabled: boolean) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
todayIssue: {
|
||||
...prev.todayIssue,
|
||||
items: {
|
||||
...prev.todayIssue.items,
|
||||
[key]: enabled,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 메인 섹션 토글
|
||||
const handleSectionToggle = useCallback(
|
||||
(
|
||||
section:
|
||||
| 'dailyReport'
|
||||
| 'monthlyExpense'
|
||||
| 'cardManagement'
|
||||
| 'debtCollection'
|
||||
| 'vat'
|
||||
| 'calendar',
|
||||
enabled: boolean
|
||||
) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
[section]: enabled,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 접대비 설정 변경
|
||||
const handleEntertainmentChange = useCallback(
|
||||
(
|
||||
key: 'enabled' | 'limitType' | 'companyType',
|
||||
value: boolean | EntertainmentLimitType | CompanyType
|
||||
) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
entertainment: {
|
||||
...prev.entertainment,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 복리후생비 설정 변경
|
||||
const handleWelfareChange = useCallback(
|
||||
(
|
||||
key: keyof typeof localSettings.welfare,
|
||||
value: boolean | WelfareLimitType | WelfareCalculationType | number
|
||||
) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
welfare: {
|
||||
...prev.welfare,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 미수금 설정 변경
|
||||
const handleReceivableChange = useCallback(
|
||||
(key: 'enabled' | 'topCompanies', value: boolean) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
receivable: {
|
||||
...prev.receivable,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(localSettings);
|
||||
onClose();
|
||||
}, [localSettings, onSave, onClose]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
setLocalSettings(settings); // 원래 설정으로 복원
|
||||
onClose();
|
||||
}, [settings, onClose]);
|
||||
|
||||
// 커스텀 스위치 (ON/OFF 라벨 포함)
|
||||
const ToggleSwitch = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-7 w-14 items-center rounded-full transition-colors',
|
||||
checked ? 'bg-cyan-500' : 'bg-gray-300'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-1 text-[10px] font-medium text-white transition-opacity',
|
||||
checked ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
ON
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute right-1 text-[10px] font-medium text-gray-500 transition-opacity',
|
||||
checked ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
OFF
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-md transition-transform',
|
||||
checked ? 'translate-x-8' : 'translate-x-1'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
// 섹션 행 컴포넌트
|
||||
const SectionRow = ({
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
hasExpand,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
hasExpand?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasExpand && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button type="button" className="p-1 hover:bg-gray-100 rounded">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
{children && (
|
||||
<CollapsibleContent className="pl-6 py-2 space-y-3 bg-gray-50">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="w-[95vw] max-w-[450px] sm:max-w-[450px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">항목 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 오늘의 이슈 섹션 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2 border-b-2 border-gray-200">
|
||||
<span className="text-sm font-semibold">오늘의 이슈</span>
|
||||
<ToggleSwitch
|
||||
checked={localSettings.todayIssue.enabled}
|
||||
onCheckedChange={handleTodayIssueToggle}
|
||||
/>
|
||||
</div>
|
||||
{localSettings.todayIssue.enabled && (
|
||||
<div className="pl-4 space-y-1">
|
||||
{(Object.keys(TODAY_ISSUE_LABELS) as Array<keyof TodayIssueSettings>).map(
|
||||
(key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<span className="text-sm text-gray-700">
|
||||
{TODAY_ISSUE_LABELS[key]}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={localSettings.todayIssue.items[key]}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTodayIssueItemToggle(key, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 일일 일보 */}
|
||||
<SectionRow
|
||||
label="일일 일보"
|
||||
checked={localSettings.dailyReport}
|
||||
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
||||
/>
|
||||
|
||||
{/* 당월 예상 지출 내역 */}
|
||||
<SectionRow
|
||||
label="당월 예상 지출 내역"
|
||||
checked={localSettings.monthlyExpense}
|
||||
onCheckedChange={(checked) => handleSectionToggle('monthlyExpense', checked)}
|
||||
/>
|
||||
|
||||
{/* 카드/가지급금 관리 */}
|
||||
<SectionRow
|
||||
label="카드/가지급금 관리"
|
||||
checked={localSettings.cardManagement}
|
||||
onCheckedChange={(checked) => handleSectionToggle('cardManagement', checked)}
|
||||
/>
|
||||
|
||||
{/* 접대비 현황 */}
|
||||
<SectionRow
|
||||
label="접대비 현황"
|
||||
checked={localSettings.entertainment.enabled}
|
||||
onCheckedChange={(checked) => handleEntertainmentChange('enabled', checked)}
|
||||
hasExpand
|
||||
isExpanded={expandedSections.entertainment}
|
||||
onToggleExpand={() => toggleSection('entertainment')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">접대비 한도 관리</span>
|
||||
<Select
|
||||
value={localSettings.entertainment.limitType}
|
||||
onValueChange={(value: EntertainmentLimitType) =>
|
||||
handleEntertainmentChange('limitType', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="annual">연간</SelectItem>
|
||||
<SelectItem value="quarterly">분기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">기업 구분</span>
|
||||
<Select
|
||||
value={localSettings.entertainment.companyType}
|
||||
onValueChange={(value: CompanyType) =>
|
||||
handleEntertainmentChange('companyType', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="large">대기업</SelectItem>
|
||||
<SelectItem value="medium">중견기업</SelectItem>
|
||||
<SelectItem value="small">중소기업</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 기업 구분 방법 설명 패널 */}
|
||||
<Collapsible
|
||||
open={expandedSections.companyTypeInfo}
|
||||
onOpenChange={() => toggleSection('companyTypeInfo')}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full py-2 px-3 text-sm text-gray-600 bg-gray-100 rounded hover:bg-gray-200"
|
||||
>
|
||||
<span>기업 구분 방법</span>
|
||||
{expandedSections.companyTypeInfo ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 p-3 bg-white border rounded text-xs space-y-4">
|
||||
{/* ■ 중소기업 판단 기준표 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-bold">■</span>
|
||||
<span className="text-sm font-medium text-gray-800">중소기업 판단 기준표</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">조건</th>
|
||||
<th className="border px-2 py-1 text-center">기준</th>
|
||||
<th className="border px-2 py-1 text-center">충족 요건</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">① 매출액</td>
|
||||
<td className="border px-2 py-1 text-center">업종별 상이</td>
|
||||
<td className="border px-2 py-1 text-center">업종별 기준 금액 이하</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">② 자산총액</td>
|
||||
<td className="border px-2 py-1 text-center">5,000억원</td>
|
||||
<td className="border px-2 py-1 text-center">미만</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">③ 독립성</td>
|
||||
<td className="border px-2 py-1 text-center">소유·경영</td>
|
||||
<td className="border px-2 py-1 text-center">대기업 계열 아님</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ① 업종별 매출액 기준 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-800">① 업종별 매출액 기준 (최근 3개년 평균)</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">업종 분류</th>
|
||||
<th className="border px-2 py-1 text-center">기준 매출액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td className="border px-2 py-1 text-center">제조업</td><td className="border px-2 py-1 text-center">1,500억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">건설업</td><td className="border px-2 py-1 text-center">1,000억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">운수업</td><td className="border px-2 py-1 text-center">1,000억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">도매업</td><td className="border px-2 py-1 text-center">1,000억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">소매업</td><td className="border px-2 py-1 text-center">600억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">정보통신업</td><td className="border px-2 py-1 text-center">600억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">전문서비스업</td><td className="border px-2 py-1 text-center">600억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">숙박·음식점업</td><td className="border px-2 py-1 text-center">400억원 이하</td></tr>
|
||||
<tr><td className="border px-2 py-1 text-center">기타 서비스업</td><td className="border px-2 py-1 text-center">400억원 이하</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ② 자산총액 기준 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-800">② 자산총액 기준</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">구분</th>
|
||||
<th className="border px-2 py-1 text-center">기준</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">5,000억원 미만</td>
|
||||
<td className="border px-2 py-1 text-center">직전 사업연도 말 자산총액</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ③ 독립성 기준 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-800">③ 독립성 기준</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">구분</th>
|
||||
<th className="border px-2 py-1 text-center">내용</th>
|
||||
<th className="border px-2 py-1 text-center">판정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">독립기업</td>
|
||||
<td className="border px-2 py-1">아래 항목에 모두 해당하지 않음</td>
|
||||
<td className="border px-2 py-1 text-center">충족</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">기업집단 소속</td>
|
||||
<td className="border px-2 py-1">공정거래법상 상호출자제한 기업집단 소속</td>
|
||||
<td className="border px-2 py-1 text-center">미충족</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">대기업 지분</td>
|
||||
<td className="border px-2 py-1">대기업이 발행주식 30% 이상 보유</td>
|
||||
<td className="border px-2 py-1 text-center">미충족</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">관계기업 합산</td>
|
||||
<td className="border px-2 py-1">관계기업 포함 시 매출액·자산 기준 초과</td>
|
||||
<td className="border px-2 py-1 text-center">미충족</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ■ 판정 결과 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-bold">■</span>
|
||||
<span className="text-sm font-medium text-gray-800">판정 결과</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border px-2 py-1 text-center">판정</th>
|
||||
<th className="border px-2 py-1 text-center">조건</th>
|
||||
<th className="border px-2 py-1 text-center">접대비 기본한도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">중소기업</td>
|
||||
<td className="border px-2 py-1 text-center">①②③ 모두 충족</td>
|
||||
<td className="border px-2 py-1 text-center">3,600만원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">일반법인</td>
|
||||
<td className="border px-2 py-1 text-center">①②③ 중 하나라도 미충족</td>
|
||||
<td className="border px-2 py-1 text-center">1,200만원</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</SectionRow>
|
||||
|
||||
{/* 복리후생비 현황 */}
|
||||
<SectionRow
|
||||
label="복리후생비 현황"
|
||||
checked={localSettings.welfare.enabled}
|
||||
onCheckedChange={(checked) => handleWelfareChange('enabled', checked)}
|
||||
hasExpand
|
||||
isExpanded={expandedSections.welfare}
|
||||
onToggleExpand={() => toggleSection('welfare')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">복리후생비 한도 관리</span>
|
||||
<Select
|
||||
value={localSettings.welfare.limitType}
|
||||
onValueChange={(value: WelfareLimitType) =>
|
||||
handleWelfareChange('limitType', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="annual">연간</SelectItem>
|
||||
<SelectItem value="quarterly">분기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">계산 방식</span>
|
||||
<Select
|
||||
value={localSettings.welfare.calculationType}
|
||||
onValueChange={(value: WelfareCalculationType) =>
|
||||
handleWelfareChange('calculationType', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fixed">직원당 정해 금액 방식</SelectItem>
|
||||
<SelectItem value="ratio">연봉 총액 X 비율 방식</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{localSettings.welfare.calculationType === 'fixed' ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">직원당 정해 금액/월</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.welfare.fixedAmountPerMonth}
|
||||
onChange={(e) =>
|
||||
handleWelfareChange(
|
||||
'fixedAmountPerMonth',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-28 h-8 text-right"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">원</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">비율</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={localSettings.welfare.ratio}
|
||||
onChange={(e) =>
|
||||
handleWelfareChange('ratio', Number(e.target.value))
|
||||
}
|
||||
className="w-20 h-8 text-right"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">연간 복리후생비총액</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={localSettings.welfare.annualTotal}
|
||||
onChange={(e) =>
|
||||
handleWelfareChange('annualTotal', Number(e.target.value))
|
||||
}
|
||||
className="w-32 h-8 text-right"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionRow>
|
||||
|
||||
{/* 미수금 현황 */}
|
||||
<SectionRow
|
||||
label="미수금 현황"
|
||||
checked={localSettings.receivable.enabled}
|
||||
onCheckedChange={(checked) => handleReceivableChange('enabled', checked)}
|
||||
hasExpand
|
||||
isExpanded={expandedSections.receivable}
|
||||
onToggleExpand={() => toggleSection('receivable')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">미수금 상위 회사 현황</span>
|
||||
<ToggleSwitch
|
||||
checked={localSettings.receivable.topCompanies}
|
||||
onCheckedChange={(checked) =>
|
||||
handleReceivableChange('topCompanies', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionRow>
|
||||
|
||||
{/* 채권추심 현황 */}
|
||||
<SectionRow
|
||||
label="채권추심 현황"
|
||||
checked={localSettings.debtCollection}
|
||||
onCheckedChange={(checked) => handleSectionToggle('debtCollection', checked)}
|
||||
/>
|
||||
|
||||
{/* 부가세 현황 */}
|
||||
<SectionRow
|
||||
label="부가세 현황"
|
||||
checked={localSettings.vat}
|
||||
onCheckedChange={(checked) => handleSectionToggle('vat', checked)}
|
||||
/>
|
||||
|
||||
{/* 캘린더 */}
|
||||
<SectionRow
|
||||
label="캘린더"
|
||||
checked={localSettings.calendar}
|
||||
onCheckedChange={(checked) => handleSectionToggle('calendar', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-center">
|
||||
<Button variant="outline" onClick={handleCancel} className="w-20">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="w-20 bg-blue-600 hover:bg-blue-700">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2
src/components/business/CEODashboard/index.ts
Normal file
2
src/components/business/CEODashboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CEODashboard } from './CEODashboard';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CalendarScheduleItem } from '../types';
|
||||
|
||||
// 색상 옵션
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'green', label: '녹색', className: 'bg-green-500' },
|
||||
{ value: 'blue', label: '파란색', className: 'bg-blue-500' },
|
||||
{ value: 'red', label: '빨간색', className: 'bg-red-500' },
|
||||
{ value: 'yellow', label: '노란색', className: 'bg-yellow-500' },
|
||||
{ value: 'purple', label: '보라색', className: 'bg-purple-500' },
|
||||
];
|
||||
|
||||
// 부서 목록 (목업)
|
||||
const DEPARTMENT_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'sales', label: '영업부' },
|
||||
{ value: 'production', label: '생산부' },
|
||||
{ value: 'quality', label: '품질부' },
|
||||
{ value: 'management', label: '경영지원부' },
|
||||
];
|
||||
|
||||
interface ScheduleFormData {
|
||||
title: string;
|
||||
department: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isAllDay: boolean; // 종일 여부 (true: 종일, false: 시간 지정)
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
color: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ScheduleDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
schedule: CalendarScheduleItem | null;
|
||||
onSave: (data: ScheduleFormData) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ScheduleDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
schedule,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: ScheduleDetailModalProps) {
|
||||
const isEditMode = schedule && schedule.id !== '';
|
||||
|
||||
const [formData, setFormData] = useState<ScheduleFormData>({
|
||||
title: '',
|
||||
department: 'all',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
isAllDay: true, // 기본값: 종일
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
color: 'green',
|
||||
content: '',
|
||||
});
|
||||
|
||||
// schedule이 변경될 때 폼 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (schedule) {
|
||||
// 시간이 있으면 종일 아님, 없으면 종일
|
||||
const hasTimeValue = !!(schedule.startTime || schedule.endTime);
|
||||
setFormData({
|
||||
title: schedule.title || '',
|
||||
department: schedule.department || 'all',
|
||||
startDate: schedule.startDate || '',
|
||||
endDate: schedule.endDate || schedule.startDate || '',
|
||||
isAllDay: !hasTimeValue, // 시간이 없으면 종일
|
||||
startTime: schedule.startTime || '09:00',
|
||||
endTime: schedule.endTime || '10:00',
|
||||
color: schedule.color || 'green',
|
||||
content: '',
|
||||
});
|
||||
}
|
||||
}, [schedule]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ScheduleFormData, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(formData);
|
||||
onClose();
|
||||
}, [formData, onSave, onClose]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (schedule?.id && onDelete) {
|
||||
onDelete(schedule.id);
|
||||
onClose();
|
||||
}
|
||||
}, [schedule, onDelete, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-6">
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="text-lg font-bold">일정 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 제목 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
제목
|
||||
</label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFieldChange('title', e.target.value)}
|
||||
placeholder="제목"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대상 (부서) */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
대상
|
||||
</label>
|
||||
<Select
|
||||
value={formData.department}
|
||||
onValueChange={(value) => handleFieldChange('department', value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="부서명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPARTMENT_OPTIONS.map((dept) => (
|
||||
<SelectItem key={dept.value} value={dept.value}>
|
||||
{dept.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기간 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
기간
|
||||
</label>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.startDate}
|
||||
onChange={(e) => handleFieldChange('startDate', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-gray-400 px-1">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.endDate}
|
||||
onChange={(e) => handleFieldChange('endDate', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시간 */}
|
||||
<div className="flex items-start gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0 pt-2">
|
||||
시간
|
||||
</label>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* 종일 체크박스 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isAllDay"
|
||||
checked={formData.isAllDay}
|
||||
onCheckedChange={(checked) =>
|
||||
handleFieldChange('isAllDay', checked === true)
|
||||
}
|
||||
/>
|
||||
<label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer">
|
||||
종일
|
||||
</label>
|
||||
</div>
|
||||
{/* 시간 선택 (종일 체크 해제 시 표시) */}
|
||||
{!formData.isAllDay && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={(value) => handleFieldChange('startTime', value)}
|
||||
placeholder="시작 시간"
|
||||
className="flex-1"
|
||||
minuteStep={5}
|
||||
/>
|
||||
<span className="text-gray-400 px-1">~</span>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={(value) => handleFieldChange('endTime', value)}
|
||||
placeholder="종료 시간"
|
||||
className="flex-1"
|
||||
minuteStep={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0">
|
||||
색상
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{COLOR_OPTIONS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={`w-8 h-8 rounded-full ${color.className} transition-all ${
|
||||
formData.color === color.value
|
||||
? 'ring-2 ring-offset-2 ring-gray-400'
|
||||
: 'hover:scale-110'
|
||||
}`}
|
||||
onClick={() => handleFieldChange('color', color.value)}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex items-start gap-6">
|
||||
<label className="w-10 text-sm font-medium text-gray-700 shrink-0 pt-2">
|
||||
내용
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => handleFieldChange('content', e.target.value)}
|
||||
placeholder="내용"
|
||||
className="flex-1 min-h-[120px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2">
|
||||
{isEditMode && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
className="bg-gray-800 text-white hover:bg-gray-900"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-gray-800 text-white hover:bg-gray-900"
|
||||
>
|
||||
{isEditMode ? '수정' : '등록'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
src/components/business/CEODashboard/modals/index.ts
Normal file
1
src/components/business/CEODashboard/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ScheduleDetailModal } from './ScheduleDetailModal';
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
|
||||
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
|
||||
import type {
|
||||
CalendarScheduleItem,
|
||||
CalendarViewType,
|
||||
CalendarDeptFilterType,
|
||||
CalendarTaskFilterType,
|
||||
} from '../types';
|
||||
|
||||
interface CalendarSectionProps {
|
||||
schedules: CalendarScheduleItem[];
|
||||
onScheduleClick?: (schedule: CalendarScheduleItem) => void;
|
||||
onScheduleEdit?: (schedule: CalendarScheduleItem) => void;
|
||||
}
|
||||
|
||||
// 일정 타입별 색상
|
||||
const SCHEDULE_TYPE_COLORS: Record<string, string> = {
|
||||
schedule: 'blue',
|
||||
order: 'green',
|
||||
construction: 'purple',
|
||||
other: 'gray',
|
||||
};
|
||||
|
||||
// 부서 필터 옵션
|
||||
const DEPT_FILTER_OPTIONS: { value: CalendarDeptFilterType; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'department', label: '부' },
|
||||
{ value: 'personal', label: '개인' },
|
||||
];
|
||||
|
||||
// 업무 필터 옵션
|
||||
const TASK_FILTER_OPTIONS: { value: CalendarTaskFilterType; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'schedule', label: '일정' },
|
||||
{ value: 'order', label: '발주' },
|
||||
{ value: 'construction', label: '시공' },
|
||||
];
|
||||
|
||||
export function CalendarSection({
|
||||
schedules,
|
||||
onScheduleClick,
|
||||
onScheduleEdit,
|
||||
}: CalendarSectionProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewType, setViewType] = useState<CalendarViewType>('month');
|
||||
const [deptFilter, setDeptFilter] = useState<CalendarDeptFilterType>('all');
|
||||
const [taskFilter, setTaskFilter] = useState<CalendarTaskFilterType>('all');
|
||||
|
||||
// 필터링된 스케줄
|
||||
const filteredSchedules = useMemo(() => {
|
||||
let result = schedules;
|
||||
|
||||
// 업무 필터
|
||||
if (taskFilter !== 'all') {
|
||||
result = result.filter((s) => s.type === taskFilter);
|
||||
}
|
||||
|
||||
// 부서 필터 (실제 구현시 부서/개인 로직 추가 필요)
|
||||
// 현재는 기본 구현만
|
||||
|
||||
return result;
|
||||
}, [schedules, taskFilter, deptFilter]);
|
||||
|
||||
// ScheduleCalendar용 이벤트 변환
|
||||
const calendarEvents: ScheduleEvent[] = useMemo(() => {
|
||||
return filteredSchedules.map((schedule) => ({
|
||||
id: schedule.id,
|
||||
// 기획서: [부서명] 제목 형식
|
||||
title: schedule.department ? `[${schedule.department}] ${schedule.title}` : schedule.title,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate,
|
||||
color: SCHEDULE_TYPE_COLORS[schedule.type] || 'gray',
|
||||
data: schedule,
|
||||
}));
|
||||
}, [filteredSchedules]);
|
||||
|
||||
// 선택된 날짜의 일정 목록
|
||||
const selectedDateSchedules = useMemo(() => {
|
||||
if (!selectedDate) return [];
|
||||
// 로컬 타임존 기준으로 날짜 문자열 생성 (UTC 변환 방지)
|
||||
const year = selectedDate.getFullYear();
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(selectedDate.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
return filteredSchedules.filter((schedule) => {
|
||||
return schedule.startDate <= dateStr && schedule.endDate >= dateStr;
|
||||
});
|
||||
}, [selectedDate, filteredSchedules]);
|
||||
|
||||
// 날짜 포맷 (기획서: "1월 6일 화요일")
|
||||
const formatSelectedDate = (date: Date) => {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const dayNames = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
||||
const dayName = dayNames[date.getDay()];
|
||||
return `${month}월 ${day}일 ${dayName}`;
|
||||
};
|
||||
|
||||
// 일정 상세 정보 포맷 (기획서: 부서명 | 날짜 | 시간)
|
||||
const formatScheduleDetail = (schedule: CalendarScheduleItem) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 부서명 또는 담당자명
|
||||
if (schedule.department) {
|
||||
parts.push(schedule.department);
|
||||
} else if (schedule.personName) {
|
||||
parts.push(schedule.personName);
|
||||
}
|
||||
|
||||
// 날짜 (여러 날인 경우)
|
||||
if (schedule.startDate !== schedule.endDate) {
|
||||
parts.push(`${schedule.startDate}~${schedule.endDate}`);
|
||||
}
|
||||
|
||||
// 시간
|
||||
if (schedule.startTime && schedule.endTime) {
|
||||
parts.push(`${schedule.startTime} ~ ${schedule.endTime}`);
|
||||
} else if (schedule.startTime) {
|
||||
parts.push(schedule.startTime);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
setSelectedDate(date);
|
||||
};
|
||||
|
||||
const handleEventClick = (event: ScheduleEvent) => {
|
||||
const schedule = event.data as CalendarScheduleItem;
|
||||
onScheduleClick?.(schedule);
|
||||
};
|
||||
|
||||
const handleMonthChange = (date: Date) => {
|
||||
setCurrentDate(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
{/* 섹션 헤더: 타이틀 + 필터들 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">캘린더</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 부서 필터 */}
|
||||
<Select
|
||||
value={deptFilter}
|
||||
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
|
||||
>
|
||||
<SelectTrigger className="w-[80px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPT_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 업무 필터 */}
|
||||
<Select
|
||||
value={taskFilter}
|
||||
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
|
||||
>
|
||||
<SelectTrigger className="w-[80px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 캘린더 영역 */}
|
||||
<div>
|
||||
<ScheduleCalendar
|
||||
events={calendarEvents}
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
onMonthChange={handleMonthChange}
|
||||
maxEventsPerDay={2}
|
||||
weekStartsOn={1} // 월요일 시작 (기획서)
|
||||
className="[&_.weekend]:bg-yellow-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 날짜 일정 목록 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
{/* 헤더: 날짜 + 일정등록 버튼 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-lg font-semibold">
|
||||
{selectedDate ? formatSelectedDate(selectedDate) : '날짜를 선택하세요'}
|
||||
</h4>
|
||||
{/* 일정등록 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => {
|
||||
// 선택된 날짜 기준으로 새 일정 등록
|
||||
const year = selectedDate?.getFullYear() || new Date().getFullYear();
|
||||
const month = String((selectedDate?.getMonth() || new Date().getMonth()) + 1).padStart(2, '0');
|
||||
const day = String(selectedDate?.getDate() || new Date().getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
onScheduleEdit?.({
|
||||
id: '',
|
||||
title: '',
|
||||
startDate: dateStr,
|
||||
endDate: dateStr,
|
||||
type: 'schedule',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
일정등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 총 N건 */}
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
총 {selectedDateSchedules.length}건
|
||||
</div>
|
||||
|
||||
{selectedDateSchedules.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
선택한 날짜에 일정이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedDateSchedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
>
|
||||
{/* 제목 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{schedule.title}
|
||||
</div>
|
||||
{/* 부서명 | 날짜 | 시간 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatScheduleDetail(schedule)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { CardManagementData } from '../types';
|
||||
|
||||
interface CardManagementSectionProps {
|
||||
data: CardManagementData;
|
||||
}
|
||||
|
||||
export function CardManagementSection({ data }: CardManagementSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push('/ko/accounting/card-management');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="카드/가지급금 관리" badge="warning" />
|
||||
|
||||
{data.warningBanner && (
|
||||
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4">
|
||||
{data.warningBanner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { DailyReportData } from '../types';
|
||||
|
||||
interface DailyReportSectionProps {
|
||||
data: DailyReportData;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
|
||||
return (
|
||||
<Card
|
||||
className={onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SectionTitle title="일일 일보" badge="info" />
|
||||
<span className="text-sm text-muted-foreground">{data.date}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { DebtCollectionData } from '../types';
|
||||
|
||||
interface DebtCollectionSectionProps {
|
||||
data: DebtCollectionData;
|
||||
}
|
||||
|
||||
export function DebtCollectionSection({ data }: DebtCollectionSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = () => {
|
||||
if (data.detailButtonPath) {
|
||||
router.push(data.detailButtonPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="채권추심 현황" badge="info" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={data.detailButtonPath ? handleClick : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { EntertainmentData } from '../types';
|
||||
|
||||
interface EntertainmentSectionProps {
|
||||
data: EntertainmentData;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function EntertainmentSection({ data, onClick }: EntertainmentSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="접대비 현황" badge="warning" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { MonthlyExpenseData } from '../types';
|
||||
|
||||
interface MonthlyExpenseSectionProps {
|
||||
data: MonthlyExpenseData;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MonthlyExpenseSection({ data, onClick }: MonthlyExpenseSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="당월 예상 지출 내역" badge="warning" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { ReceivableData } from '../types';
|
||||
|
||||
interface ReceivableSectionProps {
|
||||
data: ReceivableData;
|
||||
}
|
||||
|
||||
export function ReceivableSection({ data }: ReceivableSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleDetailClick = () => {
|
||||
if (data.detailButtonPath) {
|
||||
router.push(data.detailButtonPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle
|
||||
title="미수금 현황"
|
||||
badge="warning"
|
||||
actionButton={
|
||||
data.detailButtonLabel
|
||||
? {
|
||||
label: data.detailButtonLabel,
|
||||
onClick: handleDetailClick,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={handleDetailClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, IssueCardItem } from '../components';
|
||||
import type { TodayIssueItem, TodayIssueSettings } from '../types';
|
||||
|
||||
// 라벨 → 설정키 매핑
|
||||
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
||||
'수주': 'orders',
|
||||
'채권 추심': 'debtCollection',
|
||||
'안전 재고': 'safetyStock',
|
||||
'세금 신고': 'taxReport',
|
||||
'신규 업체 등록': 'newVendor',
|
||||
'연차': 'annualLeave',
|
||||
'지각': 'lateness',
|
||||
'결근': 'absence',
|
||||
'발주': 'purchase',
|
||||
'결재 요청': 'approvalRequest',
|
||||
};
|
||||
|
||||
interface TodayIssueSectionProps {
|
||||
items: TodayIssueItem[];
|
||||
itemSettings?: TodayIssueSettings;
|
||||
}
|
||||
|
||||
export function TodayIssueSection({ items, itemSettings }: TodayIssueSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleItemClick = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// 설정에 따라 항목 필터링
|
||||
const filteredItems = itemSettings
|
||||
? items.filter((item) => {
|
||||
const settingKey = LABEL_TO_SETTING_KEY[item.label];
|
||||
return settingKey ? itemSettings[settingKey] : true;
|
||||
})
|
||||
: items;
|
||||
|
||||
// 아이템 개수에 따른 동적 그리드 클래스
|
||||
const getGridColsClass = () => {
|
||||
const count = filteredItems.length;
|
||||
if (count <= 1) return 'grid-cols-1';
|
||||
if (count === 2) return 'grid-cols-2';
|
||||
if (count === 3) return 'grid-cols-3';
|
||||
// 4개 이상: 최대 4열, 넘치면 아래로
|
||||
return 'grid-cols-2 md:grid-cols-4';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="오늘의 이슈" badge="warning" />
|
||||
|
||||
<div className={`grid ${getGridColsClass()} gap-3`}>
|
||||
{filteredItems.map((item) => (
|
||||
<IssueCardItem
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
count={item.count}
|
||||
subLabel={item.subLabel}
|
||||
isHighlighted={item.isHighlighted}
|
||||
onClick={() => handleItemClick(item.path)}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
src/components/business/CEODashboard/sections/VatSection.tsx
Normal file
38
src/components/business/CEODashboard/sections/VatSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { VatData } from '../types';
|
||||
|
||||
interface VatSectionProps {
|
||||
data: VatData;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function VatSection({ data, onClick }: VatSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="부가세 현황" badge="warning" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, AmountCardItem, CheckPointItem } from '../components';
|
||||
import type { WelfareData } from '../types';
|
||||
|
||||
interface WelfareSectionProps {
|
||||
data: WelfareData;
|
||||
}
|
||||
|
||||
export function WelfareSection({ data }: WelfareSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="복리후생비 현황" badge="info" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.checkPoints.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
{data.checkPoints.map((cp) => (
|
||||
<CheckPointItem key={cp.id} checkpoint={cp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
10
src/components/business/CEODashboard/sections/index.ts
Normal file
10
src/components/business/CEODashboard/sections/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { TodayIssueSection } from './TodayIssueSection';
|
||||
export { DailyReportSection } from './DailyReportSection';
|
||||
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
|
||||
export { CardManagementSection } from './CardManagementSection';
|
||||
export { EntertainmentSection } from './EntertainmentSection';
|
||||
export { WelfareSection } from './WelfareSection';
|
||||
export { ReceivableSection } from './ReceivableSection';
|
||||
export { DebtCollectionSection } from './DebtCollectionSection';
|
||||
export { VatSection } from './VatSection';
|
||||
export { CalendarSection } from './CalendarSection';
|
||||
257
src/components/business/CEODashboard/types.ts
Normal file
257
src/components/business/CEODashboard/types.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* CEO Dashboard 타입 정의
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
// 체크포인트 타입 (경고/성공/에러/정보)
|
||||
export type CheckPointType = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
// 하이라이트 색상 타입
|
||||
export type HighlightColor = 'red' | 'green' | 'blue';
|
||||
|
||||
// 개별 하이라이트 항목
|
||||
export interface HighlightItem {
|
||||
text: string;
|
||||
color: HighlightColor;
|
||||
}
|
||||
|
||||
export interface CheckPoint {
|
||||
id: string;
|
||||
type: CheckPointType;
|
||||
message: string;
|
||||
highlight?: string; // 기존 호환용 (단일 하이라이트, 빨간색)
|
||||
highlights?: HighlightItem[]; // 다중 하이라이트 + 색상 지원
|
||||
}
|
||||
|
||||
// 서브 아이템 타입 (매출, 입금 등)
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
// 금액 카드 타입
|
||||
export interface AmountCard {
|
||||
id: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
subAmount?: number;
|
||||
subLabel?: string;
|
||||
previousAmount?: number;
|
||||
previousLabel?: string;
|
||||
subItems?: SubItem[]; // 다중 서브 정보 (매출, 입금 등)
|
||||
unit?: string; // 건, 원 등
|
||||
currency?: 'KRW' | 'USD'; // 통화 (기본: KRW)
|
||||
isHighlighted?: boolean; // 빨간색 강조
|
||||
}
|
||||
|
||||
// 오늘의 이슈 항목
|
||||
export interface TodayIssueItem {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number | string;
|
||||
subLabel?: string; // 예: "부가세 신고 D-15"
|
||||
isHighlighted?: boolean; // 빨간색 강조 (반전 재고)
|
||||
path: string; // 클릭 시 이동할 경로
|
||||
icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘
|
||||
}
|
||||
|
||||
// 일일 일보 데이터
|
||||
export interface DailyReportData {
|
||||
date: string; // "2026년 1월 5일 월요일"
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
}
|
||||
|
||||
// 당월 예상 지출 데이터
|
||||
export interface MonthlyExpenseData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
}
|
||||
|
||||
// 카드/가지급금 관리 데이터
|
||||
export interface CardManagementData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
warningBanner?: string; // 상단 경고 배너 메시지
|
||||
}
|
||||
|
||||
// 접대비 현황 데이터
|
||||
export interface EntertainmentData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
}
|
||||
|
||||
// 복리후생비 현황 데이터
|
||||
export interface WelfareData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
}
|
||||
|
||||
// 미수금 현황 데이터
|
||||
export interface ReceivableData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
detailButtonLabel?: string;
|
||||
detailButtonPath?: string;
|
||||
}
|
||||
|
||||
// 채권추심 현황 데이터
|
||||
export interface DebtCollectionData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
detailButtonPath?: string;
|
||||
}
|
||||
|
||||
// 부가세 현황 데이터
|
||||
export interface VatData {
|
||||
cards: AmountCard[];
|
||||
checkPoints: CheckPoint[];
|
||||
}
|
||||
|
||||
// 캘린더 일정 타입
|
||||
export interface CalendarScheduleItem {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string; // ISO date string
|
||||
endDate: string;
|
||||
startTime?: string; // "09:00"
|
||||
endTime?: string; // "12:00"
|
||||
isAllDay?: boolean;
|
||||
type: 'schedule' | 'order' | 'construction' | 'other'; // 일정, 발주, 시공
|
||||
department?: string; // 부서명
|
||||
personName?: string; // 담당자명
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// 캘린더 뷰 타입
|
||||
export type CalendarViewType = 'week' | 'month';
|
||||
|
||||
// 캘린더 부서 필터 타입
|
||||
export type CalendarDeptFilterType = 'all' | 'department' | 'personal';
|
||||
|
||||
// 캘린더 업무 필터 타입
|
||||
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction';
|
||||
|
||||
// CEO Dashboard 전체 데이터
|
||||
export interface CEODashboardData {
|
||||
todayIssue: TodayIssueItem[];
|
||||
dailyReport: DailyReportData;
|
||||
monthlyExpense: MonthlyExpenseData;
|
||||
cardManagement: CardManagementData;
|
||||
entertainment: EntertainmentData;
|
||||
welfare: WelfareData;
|
||||
receivable: ReceivableData;
|
||||
debtCollection: DebtCollectionData;
|
||||
vat: VatData;
|
||||
calendarSchedules: CalendarScheduleItem[];
|
||||
}
|
||||
|
||||
// ===== 대시보드 설정 타입 =====
|
||||
|
||||
// 오늘의 이슈 개별 항목 설정
|
||||
export interface TodayIssueSettings {
|
||||
orders: boolean; // 수주
|
||||
debtCollection: boolean; // 채권 추심
|
||||
safetyStock: boolean; // 안전 재고
|
||||
taxReport: boolean; // 세금 신고
|
||||
newVendor: boolean; // 신규 업체 등록
|
||||
annualLeave: boolean; // 연차
|
||||
lateness: boolean; // 지각
|
||||
absence: boolean; // 결근
|
||||
purchase: boolean; // 발주
|
||||
approvalRequest: boolean; // 결재 요청
|
||||
}
|
||||
|
||||
// 접대비 한도 관리 타입
|
||||
export type EntertainmentLimitType = 'annual' | 'quarterly';
|
||||
|
||||
// 기업 구분 타입
|
||||
export type CompanyType = 'large' | 'medium' | 'small';
|
||||
|
||||
// 복리후생비 한도 관리 타입
|
||||
export type WelfareLimitType = 'annual' | 'quarterly';
|
||||
|
||||
// 복리후생비 계산 방식 타입
|
||||
export type WelfareCalculationType = 'fixed' | 'ratio';
|
||||
|
||||
// 접대비 설정
|
||||
export interface EntertainmentSettings {
|
||||
enabled: boolean;
|
||||
limitType: EntertainmentLimitType;
|
||||
companyType: CompanyType;
|
||||
}
|
||||
|
||||
// 복리후생비 설정
|
||||
export interface WelfareSettings {
|
||||
enabled: boolean;
|
||||
limitType: WelfareLimitType;
|
||||
calculationType: WelfareCalculationType;
|
||||
fixedAmountPerMonth: number; // 직원당 정해 금액/월
|
||||
ratio: number; // 연봉 총액 X 비율 (%)
|
||||
annualTotal: number; // 연간 복리후생비총액
|
||||
}
|
||||
|
||||
// 대시보드 전체 설정
|
||||
export interface DashboardSettings {
|
||||
// 오늘의 이슈 섹션
|
||||
todayIssue: {
|
||||
enabled: boolean;
|
||||
items: TodayIssueSettings;
|
||||
};
|
||||
// 메인 섹션들
|
||||
dailyReport: boolean;
|
||||
monthlyExpense: boolean;
|
||||
cardManagement: boolean;
|
||||
entertainment: EntertainmentSettings;
|
||||
welfare: WelfareSettings;
|
||||
receivable: {
|
||||
enabled: boolean;
|
||||
topCompanies: boolean; // 미수금 상위 회사 현황
|
||||
};
|
||||
debtCollection: boolean;
|
||||
vat: boolean;
|
||||
calendar: boolean;
|
||||
}
|
||||
|
||||
// 기본 설정값
|
||||
export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
|
||||
todayIssue: {
|
||||
enabled: true,
|
||||
items: {
|
||||
orders: true,
|
||||
debtCollection: true,
|
||||
safetyStock: true,
|
||||
taxReport: false,
|
||||
newVendor: false,
|
||||
annualLeave: true,
|
||||
lateness: true,
|
||||
absence: false,
|
||||
purchase: false,
|
||||
approvalRequest: false,
|
||||
},
|
||||
},
|
||||
dailyReport: true,
|
||||
monthlyExpense: true,
|
||||
cardManagement: true,
|
||||
entertainment: {
|
||||
enabled: true,
|
||||
limitType: 'annual',
|
||||
companyType: 'medium',
|
||||
},
|
||||
welfare: {
|
||||
enabled: true,
|
||||
limitType: 'quarterly',
|
||||
calculationType: 'fixed',
|
||||
fixedAmountPerMonth: 200000,
|
||||
ratio: 20.5,
|
||||
annualTotal: 20000000,
|
||||
},
|
||||
receivable: {
|
||||
enabled: true,
|
||||
topCompanies: true,
|
||||
},
|
||||
debtCollection: true,
|
||||
vat: true,
|
||||
calendar: true,
|
||||
};
|
||||
@@ -1,25 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { MainDashboard } from "./MainDashboard";
|
||||
import { CEODashboard } from "./CEODashboard";
|
||||
import { PageLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
/**
|
||||
* Dashboard - 통합 대시보드 컴포넌트
|
||||
* Dashboard - 대표님 전용 대시보드
|
||||
*
|
||||
* 사용자 역할과 메뉴는 백엔드(PHP)에서 관리하며,
|
||||
* 프론트엔드는 단일 통합 대시보드만 제공합니다.
|
||||
* CEO Dashboard로 변경됨 (2026-01-07)
|
||||
* 기존 MainDashboard는 MainDashboard.tsx.backup에 백업됨
|
||||
*
|
||||
* - 역할별 데이터 필터링: 백엔드 API에서 처리
|
||||
* - 메뉴 구조: 로그인 시 받은 user.menu 데이터 사용
|
||||
* - 권한 제어: 백엔드에서 역할에 따라 데이터 제한
|
||||
* 포함 섹션:
|
||||
* - 오늘의 이슈 (수주/채권추심/반전재고/제규신고/신규업체/연차/발주/결재)
|
||||
* - 일일 일보
|
||||
* - 당월 예상 지출 내역
|
||||
* - 카드/가지급금 관리
|
||||
* - 접대비 현황
|
||||
* - 복리후생비 현황
|
||||
* - 미수금 현황
|
||||
* - 채권추심 현황
|
||||
* - 부가세 현황
|
||||
* - 캘린더
|
||||
*/
|
||||
|
||||
export function Dashboard() {
|
||||
console.log('🎨 Dashboard component rendering...');
|
||||
console.log('🎨 CEO Dashboard component rendering...');
|
||||
return (
|
||||
<Suspense fallback={<PageLoadingSpinner text="대시보드를 불러오는 중..." />}>
|
||||
<MainDashboard />
|
||||
</Suspense>
|
||||
<Suspense fallback={<PageLoadingSpinner text="대시보드를 불러오는 중..." />}>
|
||||
<CEODashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/components/business/Dashboard.tsx.backup2
Normal file
25
src/components/business/Dashboard.tsx.backup2
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { MainDashboard } from "./MainDashboard";
|
||||
import { PageLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
/**
|
||||
* Dashboard - 통합 대시보드 컴포넌트
|
||||
*
|
||||
* 사용자 역할과 메뉴는 백엔드(PHP)에서 관리하며,
|
||||
* 프론트엔드는 단일 통합 대시보드만 제공합니다.
|
||||
*
|
||||
* - 역할별 데이터 필터링: 백엔드 API에서 처리
|
||||
* - 메뉴 구조: 로그인 시 받은 user.menu 데이터 사용
|
||||
* - 권한 제어: 백엔드에서 역할에 따라 데이터 제한
|
||||
*/
|
||||
|
||||
export function Dashboard() {
|
||||
console.log('🎨 Dashboard component rendering...');
|
||||
return (
|
||||
<Suspense fallback={<PageLoadingSpinner text="대시보드를 불러오는 중..." />}>
|
||||
<MainDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
2650
src/components/business/MainDashboard.tsx.backup
Normal file
2650
src/components/business/MainDashboard.tsx.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
PostApiData,
|
||||
@@ -61,6 +63,7 @@ export async function getPosts(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] getPosts error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -97,6 +100,7 @@ export async function getPost(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] getPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -138,6 +142,7 @@ export async function createPost(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] createPost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -180,6 +185,7 @@ export async function updatePost(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] updatePost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -215,6 +221,7 @@ export async function deletePost(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] deletePost error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -253,6 +260,7 @@ export async function getComments(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] getComments error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -290,6 +298,7 @@ export async function createComment(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] createComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -328,6 +337,7 @@ export async function updateComment(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] updateComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -364,6 +374,7 @@ export async function deleteComment(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[CustomerCenterActions] deleteComment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
AttendanceRecord,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Card, CardFormData, CardStatus } from './types';
|
||||
|
||||
@@ -272,6 +274,7 @@ export async function deleteCards(ids: string[]): Promise<{ success: boolean; er
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteCards] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
@@ -314,6 +316,7 @@ export async function deleteDepartmentsMany(
|
||||
results,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteDepartmentsMany] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
|
||||
import type { Employee, EmployeeFormData, EmployeeStats } from './types';
|
||||
@@ -98,6 +100,7 @@ export async function getEmployees(params?: {
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] getEmployees error:', error);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
@@ -129,6 +132,7 @@ export async function getEmployeeById(id: string): Promise<Employee | null | { _
|
||||
|
||||
return transformApiToFrontend(result.data);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] getEmployeeById error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -175,6 +179,7 @@ export async function createEmployee(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] createEmployee error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -225,6 +230,7 @@ export async function updateEmployee(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] updateEmployee error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -262,6 +268,7 @@ export async function deleteEmployee(id: string): Promise<{ success: boolean; er
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] deleteEmployee error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -302,6 +309,7 @@ export async function deleteEmployees(ids: string[]): Promise<{ success: boolean
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] deleteEmployees error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -341,6 +349,7 @@ export async function getEmployeeStats(): Promise<EmployeeStats | null | { __aut
|
||||
averageTenure: result.data.average_tenure ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] getEmployeeStats error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -392,6 +401,7 @@ export async function getPositions(type?: 'rank' | 'title'): Promise<PositionIte
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] getPositions error:', error);
|
||||
return [];
|
||||
}
|
||||
@@ -440,6 +450,7 @@ export async function getDepartments(): Promise<DepartmentItem[]> {
|
||||
const departments = Array.isArray(result.data) ? result.data : result.data.data || [];
|
||||
return departments;
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] getDepartments error:', error);
|
||||
return [];
|
||||
}
|
||||
@@ -502,6 +513,7 @@ export async function uploadProfileImage(inputFormData: FormData): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[EmployeeActions] uploadProfileImage error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
@@ -291,6 +293,7 @@ export async function getLeaves(params?: GetLeavesParams): Promise<{
|
||||
error: result.message || '휴가 목록 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getLeaves] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -329,6 +332,7 @@ export async function getLeaveById(id: number): Promise<{
|
||||
error: result.message || '휴가 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getLeaveById] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -375,6 +379,7 @@ export async function createLeave(
|
||||
error: result.message || '휴가 신청에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createLeave] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -415,6 +420,7 @@ export async function approveLeave(
|
||||
error: result.message || '휴가 승인에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[approveLeave] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -455,6 +461,7 @@ export async function rejectLeave(
|
||||
error: result.message || '휴가 반려에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[rejectLeave] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -495,6 +502,7 @@ export async function cancelLeave(
|
||||
error: result.message || '휴가 취소에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[cancelLeave] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -537,6 +545,7 @@ export async function getMyLeaveBalance(year?: number): Promise<{
|
||||
error: result.message || '잔여 휴가 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getMyLeaveBalance] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -582,6 +591,7 @@ export async function getUserLeaveBalance(
|
||||
error: result.message || '잔여 휴가 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getUserLeaveBalance] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -625,6 +635,7 @@ export async function setLeaveBalance(
|
||||
error: result.message || '잔여 휴가 설정에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[setLeaveBalance] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -658,6 +669,7 @@ export async function deleteLeave(id: number): Promise<{ success: boolean; error
|
||||
error: result.message || '휴가 삭제에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteLeave] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -689,6 +701,7 @@ export async function approveLeavesMany(ids: number[]): Promise<{
|
||||
error: allSuccess ? undefined : '일부 휴가 승인에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[approveLeavesMany] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -723,6 +736,7 @@ export async function rejectLeavesMany(
|
||||
error: allSuccess ? undefined : '일부 휴가 반려에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[rejectLeavesMany] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -781,6 +795,7 @@ export async function getLeaveBalances(params?: GetLeaveBalancesParams): Promise
|
||||
error: result.message || '휴가 사용현황 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getLeaveBalances] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -927,6 +942,7 @@ export async function getLeaveGrants(params?: GetLeaveGrantsParams): Promise<{
|
||||
error: result.message || '휴가 부여 이력 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getLeaveGrants] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -972,6 +988,7 @@ export async function createLeaveGrant(
|
||||
error: result.message || '휴가 부여에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createLeaveGrant] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -1005,6 +1022,7 @@ export async function deleteLeaveGrant(id: number): Promise<{ success: boolean;
|
||||
error: result.message || '휴가 부여 삭제에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteLeaveGrant] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -1098,6 +1116,7 @@ export async function getActiveEmployees(): Promise<{
|
||||
error: result.message || '직원 목록 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getActiveEmployees] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
ReceivingItem,
|
||||
@@ -240,6 +242,7 @@ export async function getReceivings(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] getReceivings error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -279,6 +282,7 @@ export async function getReceivingStats(): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToStats(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] getReceivingStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -313,6 +317,7 @@ export async function getReceivingById(id: string): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] getReceivingById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -346,6 +351,7 @@ export async function createReceiving(
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] createReceiving error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -380,6 +386,7 @@ export async function updateReceiving(
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] updateReceiving error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -411,6 +418,7 @@ export async function deleteReceiving(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] deleteReceiving error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -445,6 +453,7 @@ export async function processReceiving(
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ReceivingActions] processReceiving error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
StockItem,
|
||||
@@ -257,6 +259,7 @@ export async function getStocks(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[StockActions] getStocks error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -296,6 +299,7 @@ export async function getStockStats(): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToStats(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[StockActions] getStockStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -330,6 +334,7 @@ export async function getStockStatsByType(): Promise<{
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[StockActions] getStockStatsByType error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -364,6 +369,7 @@ export async function getStockById(id: string): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[StockActions] getStockById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
ShipmentItem,
|
||||
@@ -373,6 +375,7 @@ export async function getShipments(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getShipments error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -416,6 +419,7 @@ export async function getShipmentStats(): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToStats(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getShipmentStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -454,6 +458,7 @@ export async function getShipmentStatsByStatus(): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToStatsByStatus(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getShipmentStatsByStatus error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -492,6 +497,7 @@ export async function getShipmentById(id: string): Promise<{
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getShipmentById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -530,6 +536,7 @@ export async function createShipment(
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] createShipment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -569,6 +576,7 @@ export async function updateShipment(
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] updateShipment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -623,6 +631,7 @@ export async function updateShipmentStatus(
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] updateShipmentStatus error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -657,6 +666,7 @@ export async function deleteShipment(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] deleteShipment error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -695,6 +705,7 @@ export async function getLotOptions(): Promise<{
|
||||
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getLotOptions error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -733,6 +744,7 @@ export async function getLogisticsOptions(): Promise<{
|
||||
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getLogisticsOptions error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -771,6 +783,7 @@ export async function getVehicleTonnageOptions(): Promise<{
|
||||
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ShipmentActions] getVehicleTonnageOptions error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { PricingData, ItemInfo } from './types';
|
||||
|
||||
@@ -195,6 +197,7 @@ export async function getPricingById(id: string): Promise<PricingData | null> {
|
||||
|
||||
return transformApiToFrontend(result.data);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] getPricingById error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -245,6 +248,7 @@ export async function getItemInfo(itemId: string): Promise<ItemInfo | null> {
|
||||
unit: item.unit || 'EA',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] getItemInfo error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -300,6 +304,7 @@ export async function createPricing(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] createPricing error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -362,6 +367,7 @@ export async function updatePricing(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] updatePricing error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -409,6 +415,7 @@ export async function deletePricing(id: string): Promise<{ success: boolean; err
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] deletePricing error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -459,6 +466,7 @@ export async function finalizePricing(id: string): Promise<{ success: boolean; d
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] finalizePricing error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -539,6 +547,7 @@ export async function getPricingRevisions(priceId: string): Promise<{
|
||||
data: revisions,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PricingActions] getPricingRevisions error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Process, ProcessFormData, ClassificationRule } from '@/types/process';
|
||||
|
||||
@@ -167,6 +169,7 @@ export async function getProcessList(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getProcessList] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -198,6 +201,7 @@ export async function getProcessById(id: string): Promise<{ success: boolean; da
|
||||
|
||||
return { success: true, data: transformApiToFrontend(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getProcessById] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -231,6 +235,7 @@ export async function createProcess(data: ProcessFormData): Promise<{ success: b
|
||||
|
||||
return { success: true, data: transformApiToFrontend(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createProcess] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -264,6 +269,7 @@ export async function updateProcess(id: string, data: ProcessFormData): Promise<
|
||||
|
||||
return { success: true, data: transformApiToFrontend(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateProcess] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -294,6 +300,7 @@ export async function deleteProcess(id: string): Promise<{ success: boolean; err
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteProcess] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -325,6 +332,7 @@ export async function deleteProcesses(ids: string[]): Promise<{ success: boolean
|
||||
|
||||
return { success: true, deletedCount: result.data.deleted_count };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteProcesses] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -355,6 +363,7 @@ export async function toggleProcessActive(id: string): Promise<{ success: boolea
|
||||
|
||||
return { success: true, data: transformApiToFrontend(result.data) };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[toggleProcessActive] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -401,6 +410,7 @@ export async function getProcessOptions(): Promise<{
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getProcessOptions] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -445,6 +455,7 @@ export async function getProcessStats(): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getProcessStats] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -492,6 +503,7 @@ export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getDepartmentOptions] Error:', error);
|
||||
return [];
|
||||
}
|
||||
@@ -552,6 +564,7 @@ export async function getItemList(params?: GetItemListParams): Promise<ItemOptio
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getItemList] Error:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { WorkOrder, WorkerStatus, ProcessType, DashboardStats } from './types';
|
||||
|
||||
@@ -182,6 +184,7 @@ export async function getDashboardData(processType?: ProcessType): Promise<{
|
||||
stats,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ProductionDashboardActions] getDashboardData error:', error);
|
||||
return {
|
||||
...emptyResult,
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
WorkOrder,
|
||||
@@ -122,6 +124,7 @@ export async function getWorkOrders(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getWorkOrders error:', error);
|
||||
return { ...emptyResponse, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -165,6 +168,7 @@ export async function getWorkOrderStats(): Promise<{
|
||||
data: transformStatsApiToFrontend(statsApi),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getWorkOrderStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -206,6 +210,7 @@ export async function getWorkOrderById(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getWorkOrderById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -256,6 +261,7 @@ export async function createWorkOrder(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] createWorkOrder error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -298,6 +304,7 @@ export async function updateWorkOrder(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] updateWorkOrder error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -327,6 +334,7 @@ export async function deleteWorkOrder(id: string): Promise<{ success: boolean; e
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] deleteWorkOrder error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -367,6 +375,7 @@ export async function updateWorkOrderStatus(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] updateWorkOrderStatus error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -411,6 +420,7 @@ export async function assignWorkOrder(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] assignWorkOrder error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -451,6 +461,7 @@ export async function toggleBendingField(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] toggleBendingField error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -495,6 +506,7 @@ export async function addWorkOrderIssue(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] addWorkOrderIssue error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -532,6 +544,7 @@ export async function resolveWorkOrderIssue(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] resolveWorkOrderIssue error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -619,6 +632,7 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
data: salesOrders,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getSalesOrdersForWorkOrder error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -698,6 +712,7 @@ export async function getDepartmentsWithUsers(): Promise<{
|
||||
data: departments,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getDepartmentsWithUsers error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
/**
|
||||
* 작업실적 관리 Server Actions
|
||||
*
|
||||
@@ -133,6 +135,7 @@ export async function getWorkResults(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] getWorkResults error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -203,6 +206,7 @@ export async function getWorkResultStats(params?: {
|
||||
data: transformStatsApiToFrontend(statsApi),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] getWorkResultStats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -256,6 +260,7 @@ export async function getWorkResultById(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] getWorkResultById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -335,6 +340,7 @@ export async function createWorkResult(data: {
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] createWorkResult error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -388,6 +394,7 @@ export async function updateWorkResult(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] updateWorkResult error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -429,6 +436,7 @@ export async function deleteWorkResult(id: string): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] deleteWorkResult error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -476,6 +484,7 @@ export async function toggleInspection(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] toggleInspection error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -523,6 +532,7 @@ export async function togglePackaging(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkResultActions] togglePackaging error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
|
||||
|
||||
@@ -131,6 +133,7 @@ export async function getMyWorkOrders(): Promise<{
|
||||
data: workOrders,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] getMyWorkOrders error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -183,6 +186,7 @@ export async function completeWorkOrder(
|
||||
lotNo,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] completeWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -266,6 +270,7 @@ export async function getMaterialsForWorkOrder(
|
||||
data: materials,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] getMaterialsForWorkOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -308,6 +313,7 @@ export async function registerMaterialInput(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] registerMaterialInput error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -353,6 +359,7 @@ export async function reportIssue(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] reportIssue error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -464,6 +471,7 @@ export async function getProcessSteps(
|
||||
data: steps,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] getProcessSteps error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -506,6 +514,7 @@ export async function requestInspection(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] requestInspection error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
/**
|
||||
* 검사 관리 Server Actions
|
||||
* API 연동 완료 (2025-12-26)
|
||||
@@ -263,6 +265,7 @@ export async function getInspections(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] getInspections error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -346,6 +349,7 @@ export async function getInspectionStats(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] getInspectionStats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -407,6 +411,7 @@ export async function getInspectionById(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] getInspectionById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -493,6 +498,7 @@ export async function createInspection(data: {
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] createInspection error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -584,6 +590,7 @@ export async function updateInspection(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] updateInspection error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -633,6 +640,7 @@ export async function deleteInspection(id: string): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] deleteInspection error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -700,6 +708,7 @@ export async function completeInspection(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[InspectionActions] completeInspection error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
Quote,
|
||||
@@ -131,6 +133,7 @@ export async function getQuotes(params?: QuoteListParams): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getQuotes error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -207,6 +210,7 @@ export async function getQuoteById(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getQuoteById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -263,6 +267,7 @@ export async function createQuote(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] createQuote error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -320,6 +325,7 @@ export async function updateQuote(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] updateQuote error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -364,6 +370,7 @@ export async function deleteQuote(id: string): Promise<{ success: boolean; error
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] deleteQuote error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -409,6 +416,7 @@ export async function bulkDeleteQuotes(ids: string[]): Promise<{ success: boolea
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] bulkDeleteQuotes error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -461,6 +469,7 @@ export async function finalizeQuote(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] finalizeQuote error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -513,6 +522,7 @@ export async function cancelFinalizeQuote(id: string): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] cancelFinalizeQuote error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -567,6 +577,7 @@ export async function convertQuoteToOrder(id: string): Promise<{
|
||||
orderId: result.data?.order?.id ? String(result.data.order.id) : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] convertQuoteToOrder error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -618,6 +629,7 @@ export async function getQuoteNumberPreview(): Promise<{
|
||||
data: result.data?.quote_number || result.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getQuoteNumberPreview error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -669,6 +681,7 @@ export async function generateQuotePdf(id: string): Promise<{
|
||||
data: blob,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] generateQuotePdf error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -717,6 +730,7 @@ export async function sendQuoteEmail(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] sendQuoteEmail error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -765,6 +779,7 @@ export async function sendQuoteKakao(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] sendQuoteKakao error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -856,6 +871,7 @@ export async function getFinishedGoods(category?: string): Promise<{
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getFinishedGoods error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -948,6 +964,7 @@ export async function calculateBomBulk(items: BomCalculateItem[]): Promise<{
|
||||
data: result.data || [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] calculateBomBulk error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -1023,6 +1040,7 @@ export async function getQuotesSummary(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getQuotesSummary error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -1067,6 +1085,7 @@ export async function getSiteNames(): Promise<{
|
||||
data: siteNames,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[QuoteActions] getSiteNames error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ComprehensiveAnalysisData } from './types';
|
||||
|
||||
@@ -165,6 +167,7 @@ export async function getComprehensiveAnalysis(params?: {
|
||||
data: transformedData,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ComprehensiveAnalysisActions] getComprehensiveAnalysis error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -205,6 +208,7 @@ export async function approveIssue(issueId: string): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ComprehensiveAnalysisActions] approveIssue error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -246,6 +250,7 @@ export async function rejectIssue(issueId: string, reason?: string): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[ComprehensiveAnalysisActions] rejectIssue error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
|
||||
|
||||
@@ -110,6 +112,7 @@ export async function getAccountInfo(): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[AccountInfoActions] getAccountInfo error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -159,6 +162,7 @@ export async function withdrawAccount(password: string): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[AccountInfoActions] withdrawAccount error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -208,6 +212,7 @@ export async function suspendTenant(): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[AccountInfoActions] suspendTenant error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -259,6 +264,7 @@ export async function updateAgreements(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[AccountInfoActions] updateAgreements error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -376,6 +382,7 @@ export async function uploadProfileImage(formData: FormData): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[AccountInfoActions] uploadProfileImage error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Account, AccountFormData, AccountStatus } from './types';
|
||||
import { BANK_LABELS } from './types';
|
||||
@@ -128,6 +130,7 @@ export async function getBankAccounts(params?: {
|
||||
};
|
||||
return { success: true, data: accounts, meta };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getBankAccounts] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -170,6 +173,7 @@ export async function getBankAccount(id: number): Promise<{
|
||||
const account = transformApiToFrontend(result.data);
|
||||
return { success: true, data: account };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getBankAccount] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -214,6 +218,7 @@ export async function createBankAccount(data: AccountFormData): Promise<{
|
||||
const account = transformApiToFrontend(result.data);
|
||||
return { success: true, data: account };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createBankAccount] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -261,6 +266,7 @@ export async function updateBankAccount(
|
||||
const account = transformApiToFrontend(result.data);
|
||||
return { success: true, data: account };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateBankAccount] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -300,6 +306,7 @@ export async function deleteBankAccount(id: number): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteBankAccount] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -341,6 +348,7 @@ export async function toggleBankAccountStatus(id: number): Promise<{
|
||||
const account = transformApiToFrontend(result.data);
|
||||
return { success: true, data: account };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[toggleBankAccountStatus] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -382,6 +390,7 @@ export async function setPrimaryBankAccount(id: number): Promise<{
|
||||
const account = transformApiToFrontend(result.data);
|
||||
return { success: true, data: account };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[setPrimaryBankAccount] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -412,6 +421,7 @@ export async function deleteBankAccounts(ids: number[]): Promise<{
|
||||
|
||||
return { success: true, deletedCount: successCount };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteBankAccounts] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -110,6 +112,7 @@ export async function getAttendanceSetting(): Promise<ApiResponse<AttendanceSett
|
||||
data: transformFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('getAttendanceSetting error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -149,6 +152,7 @@ export async function updateAttendanceSetting(
|
||||
data: transformFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('updateAttendanceSetting error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -207,6 +211,7 @@ export async function getDepartments(): Promise<ApiResponse<Department[]>> {
|
||||
|
||||
return { success: true, data: departments };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('getDepartments error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { CompanyFormData } from './types';
|
||||
|
||||
@@ -74,6 +76,7 @@ export async function getCompanyInfo(): Promise<{
|
||||
|
||||
return { success: true, data: formData };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getCompanyInfo] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -123,6 +126,7 @@ export async function updateCompanyInfo(
|
||||
const updatedData = transformApiToFrontend(result.data);
|
||||
return { success: true, data: updatedData };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateCompanyInfo] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -271,6 +275,7 @@ export async function uploadCompanyLogo(formData: FormData): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[uploadCompanyLogo] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { LeavePolicySettings } from './types';
|
||||
|
||||
@@ -99,6 +101,7 @@ export async function getLeavePolicy(): Promise<{
|
||||
data: transformedData,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[LeavePolicyActions] getLeavePolicy error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -155,6 +158,7 @@ export async function updateLeavePolicy(data: Partial<LeavePolicySettings>): Pro
|
||||
data: transformedData,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[LeavePolicyActions] updateLeavePolicy error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { NotificationSettings } from './types';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
|
||||
@@ -52,6 +54,7 @@ export async function getNotificationSettings(): Promise<{
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[NotificationActions] getNotificationSettings error:', error);
|
||||
return {
|
||||
success: true,
|
||||
@@ -101,6 +104,7 @@ export async function saveNotificationSettings(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[NotificationActions] saveNotificationSettings error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { PaymentApiData, PaymentHistory } from './types';
|
||||
import { transformApiToFrontend } from './utils';
|
||||
@@ -85,6 +87,7 @@ export async function getPayments(params?: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PaymentActions] getPayments error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -233,6 +236,7 @@ export async function getPaymentStatement(id: string): Promise<{
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PaymentActions] getPaymentStatement error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Role, RoleStats, PermissionMatrix, MenuTreeItem, ApiResponse, PaginatedResponse } from './types';
|
||||
@@ -43,6 +45,7 @@ export async function fetchRoles(params?: {
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to fetch roles:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '역할 목록 조회 실패' };
|
||||
}
|
||||
@@ -71,6 +74,7 @@ export async function fetchRole(id: number): Promise<ApiResponse<Role>> {
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to fetch role:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '역할 조회 실패' };
|
||||
}
|
||||
@@ -107,6 +111,7 @@ export async function createRole(data: {
|
||||
revalidatePath('/settings/permissions');
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to create role:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '역할 생성 실패' };
|
||||
}
|
||||
@@ -147,6 +152,7 @@ export async function updateRole(
|
||||
revalidatePath(`/settings/permissions/${id}`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to update role:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '역할 수정 실패' };
|
||||
}
|
||||
@@ -178,6 +184,7 @@ export async function deleteRole(id: number): Promise<ApiResponse<void>> {
|
||||
revalidatePath('/settings/permissions');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to delete role:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '역할 삭제 실패' };
|
||||
}
|
||||
@@ -206,6 +213,7 @@ export async function fetchRoleStats(): Promise<ApiResponse<RoleStats>> {
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to fetch role stats:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '역할 통계 조회 실패' };
|
||||
}
|
||||
@@ -234,6 +242,7 @@ export async function fetchActiveRoles(): Promise<ApiResponse<Role[]>> {
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to fetch active roles:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '활성 역할 목록 조회 실패' };
|
||||
}
|
||||
@@ -267,6 +276,7 @@ export async function fetchPermissionMenus(): Promise<ApiResponse<{
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to fetch permission menus:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '메뉴 트리 조회 실패' };
|
||||
}
|
||||
@@ -295,6 +305,7 @@ export async function fetchPermissionMatrix(roleId: number): Promise<ApiResponse
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to fetch permission matrix:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '권한 매트릭스 조회 실패' };
|
||||
}
|
||||
@@ -337,6 +348,7 @@ export async function togglePermission(
|
||||
revalidatePath(`/settings/permissions/${roleId}`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to toggle permission:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '권한 토글 실패' };
|
||||
}
|
||||
@@ -368,6 +380,7 @@ export async function allowAllPermissions(roleId: number): Promise<ApiResponse<{
|
||||
revalidatePath(`/settings/permissions/${roleId}`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to allow all permissions:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '전체 허용 실패' };
|
||||
}
|
||||
@@ -399,6 +412,7 @@ export async function denyAllPermissions(roleId: number): Promise<ApiResponse<{
|
||||
revalidatePath(`/settings/permissions/${roleId}`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to deny all permissions:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '전체 거부 실패' };
|
||||
}
|
||||
@@ -430,6 +444,7 @@ export async function resetPermissions(roleId: number): Promise<ApiResponse<{ co
|
||||
revalidatePath(`/settings/permissions/${roleId}`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('Failed to reset permissions:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '권한 초기화 실패' };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Popup, PopupFormData } from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils';
|
||||
@@ -80,6 +82,7 @@ export async function getPopups(params?: {
|
||||
|
||||
return result.data.data.map(transformApiToFrontend);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PopupActions] getPopups error:', error);
|
||||
return [];
|
||||
}
|
||||
@@ -116,6 +119,7 @@ export async function getPopupById(id: string): Promise<Popup | null> {
|
||||
|
||||
return transformApiToFrontend(result.data);
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PopupActions] getPopupById error:', error);
|
||||
return null;
|
||||
}
|
||||
@@ -170,6 +174,7 @@ export async function createPopup(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PopupActions] createPopup error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -228,6 +233,7 @@ export async function updatePopup(
|
||||
data: transformApiToFrontend(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PopupActions] updatePopup error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -275,6 +281,7 @@ export async function deletePopup(id: string): Promise<{ success: boolean; error
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PopupActions] deletePopup error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -300,6 +307,7 @@ export async function deletePopups(ids: string[]): Promise<{ success: boolean; e
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[PopupActions] deletePopups error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Rank } from './types';
|
||||
|
||||
@@ -82,6 +84,7 @@ export async function getRanks(params?: {
|
||||
const ranks = result.data.map(transformApiToFrontend);
|
||||
return { success: true, data: ranks };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getRanks] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -133,6 +136,7 @@ export async function createRank(data: {
|
||||
const rank = transformApiToFrontend(result.data);
|
||||
return { success: true, data: rank };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -182,6 +186,7 @@ export async function updateRank(
|
||||
const rank = transformApiToFrontend(result.data);
|
||||
return { success: true, data: rank };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -221,6 +226,7 @@ export async function deleteRank(id: number): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -263,6 +269,7 @@ export async function reorderRanks(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[reorderRanks] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { SubscriptionApiData, UsageApiData, SubscriptionInfo } from './types';
|
||||
import { transformApiToFrontend } from './utils';
|
||||
@@ -52,6 +54,7 @@ export async function getCurrentSubscription(): Promise<{
|
||||
data: result.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[SubscriptionActions] getCurrentSubscription error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -109,6 +112,7 @@ export async function getUsage(): Promise<{
|
||||
data: result.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[SubscriptionActions] getUsage error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -162,6 +166,7 @@ export async function cancelSubscription(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[SubscriptionActions] cancelSubscription error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -220,6 +225,7 @@ export async function requestDataExport(
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[SubscriptionActions] requestDataExport error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -258,6 +264,7 @@ export async function getSubscriptionData(): Promise<{
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[SubscriptionActions] getSubscriptionData error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Title } from './types';
|
||||
|
||||
@@ -82,6 +84,7 @@ export async function getTitles(params?: {
|
||||
const titles = result.data.map(transformApiToFrontend);
|
||||
return { success: true, data: titles };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[getTitles] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -133,6 +136,7 @@ export async function createTitle(data: {
|
||||
const title = transformApiToFrontend(result.data);
|
||||
return { success: true, data: title };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[createTitle] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -182,6 +186,7 @@ export async function updateTitle(
|
||||
const title = transformApiToFrontend(result.data);
|
||||
return { success: true, data: title };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[updateTitle] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -221,6 +226,7 @@ export async function deleteTitle(id: number): Promise<{
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[deleteTitle] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
@@ -263,6 +269,7 @@ export async function reorderTitles(
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('[reorderTitles] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://sam.kr:8080';
|
||||
@@ -118,6 +120,7 @@ export async function getWorkSetting(): Promise<{
|
||||
data: transformFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('getWorkSetting error:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -166,6 +169,7 @@ export async function updateWorkSetting(
|
||||
data: transformFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error('updateWorkSetting error:', error);
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
X,
|
||||
BarChart3,
|
||||
Award,
|
||||
Bell,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -40,6 +42,29 @@ import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
||||
import { useMenuPolling } from '@/hooks/useMenuPolling';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
// 목업 회사 데이터
|
||||
const MOCK_COMPANIES = [
|
||||
{ id: 'all', name: '전체' },
|
||||
{ id: 'company1', name: '(주)삼성건설' },
|
||||
{ id: 'company2', name: '현대건설(주)' },
|
||||
{ id: 'company3', name: '대우건설(주)' },
|
||||
{ id: 'company4', name: 'GS건설(주)' },
|
||||
];
|
||||
|
||||
// 목업 알림 데이터
|
||||
const MOCK_NOTIFICATIONS = [
|
||||
{ id: 1, category: '안내', title: '시스템 점검 안내', date: '2025.09.03 12:23', isNew: true },
|
||||
{ id: 2, category: '공지사항', title: '신규 기능 업데이트', date: '2025.09.03 12:23', isNew: false },
|
||||
{ id: 3, category: '안내', title: '보안 업데이트 완료', date: '2025.09.03 12:23', isNew: false },
|
||||
];
|
||||
|
||||
interface AuthenticatedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -62,6 +87,11 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
// 사용자 정보 상태
|
||||
const [userName, setUserName] = useState<string>("사용자");
|
||||
const [userPosition, setUserPosition] = useState<string>("직책");
|
||||
const [userEmail, setUserEmail] = useState<string>("");
|
||||
const [userCompany, setUserCompany] = useState<string>("");
|
||||
|
||||
// 회사 선택 상태 (목업)
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||
|
||||
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
||||
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
||||
@@ -107,9 +137,11 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
|
||||
// 사용자 이름과 직책 설정
|
||||
// 사용자 이름, 직책, 이메일, 회사 설정
|
||||
setUserName(userData.name || "사용자");
|
||||
setUserPosition(userData.position || "직책");
|
||||
setUserEmail(userData.email || "");
|
||||
setUserCompany(userData.company || userData.company_name || "");
|
||||
|
||||
// 서버에서 받은 메뉴 배열이 있으면 사용, 없으면 기본 메뉴 사용
|
||||
if (userData.menu && Array.isArray(userData.menu) && userData.menu.length > 0) {
|
||||
@@ -200,6 +232,11 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
if (result.parentId && !expandedMenus.includes(result.parentId)) {
|
||||
setExpandedMenus(prev => [...prev, result.parentId!]);
|
||||
}
|
||||
} else {
|
||||
// 대시보드 등 어떤 메뉴에도 속하지 않는 페이지일 경우 모든 메뉴 닫기
|
||||
if (expandedMenus.length > 0) {
|
||||
setExpandedMenus([]);
|
||||
}
|
||||
}
|
||||
}, [pathname, menuItems, setActiveMenu, expandedMenus]);
|
||||
|
||||
@@ -257,16 +294,40 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
// 모바일 레이아웃
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* 모바일 헤더 - sam-design 스타일 */}
|
||||
<header className="clean-glass sticky top-0 z-40 px-4 py-4 m-3 rounded-2xl clean-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 좌측 영역: 대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 좌측 영역: 햄버거 메뉴 + (대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼) */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 햄버거 메뉴 - 좌측 맨 앞 */}
|
||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80 p-1 bg-transparent border-none">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>메뉴</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Sidebar
|
||||
menuItems={menuItems}
|
||||
activeMenu={activeMenu}
|
||||
expandedMenus={expandedMenus}
|
||||
sidebarCollapsed={false}
|
||||
isMobile={true}
|
||||
onMenuClick={handleMenuClick}
|
||||
onToggleSubmenu={toggleSubmenu}
|
||||
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{isDashboard ? (
|
||||
// 대시보드: 로고만 표시
|
||||
<div
|
||||
className="flex items-center space-x-3 ml-4 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
className="flex items-center space-x-3 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={handleGoHome}
|
||||
title="대시보드로 이동"
|
||||
>
|
||||
@@ -279,7 +340,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
</div>
|
||||
) : (
|
||||
// 다른 페이지: 이전/홈 버튼 표시
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -302,7 +363,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 영역: 종합분석, 품질인정심사, 검색, 테마, 유저, 메뉴 */}
|
||||
{/* 우측 영역: 종합분석, 품질인정심사, 유저 드롭다운, 메뉴 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 종합분석 바로가기 */}
|
||||
<Button
|
||||
@@ -326,85 +387,144 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
<Award className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* 검색 아이콘 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* 테마 토글 */}
|
||||
{/* 알림 버튼 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center">
|
||||
{theme === 'light' && <Sun className="h-5 w-5" />}
|
||||
{theme === 'dark' && <Moon className="h-5 w-5" />}
|
||||
{theme === 'senior' && <Accessibility className="h-5 w-5" />}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center relative"
|
||||
>
|
||||
<Bell className="h-5 w-5 text-amber-600" />
|
||||
{/* 알림 있을 때 빨간 점 */}
|
||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
일반모드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
다크모드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('senior')}>
|
||||
<Accessibility className="mr-2 h-4 w-4" />
|
||||
시니어모드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
{/* 알림 리스트 */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{MOCK_NOTIFICATIONS.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
{/* 이미지 플레이스홀더 */}
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
|
||||
IMG
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs">{notification.category}</span>
|
||||
{notification.isNew && (
|
||||
<span className="w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
||||
N
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-foreground font-medium text-sm mt-1 truncate">{notification.title}</p>
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<span className="flex-shrink-0 text-muted-foreground text-xs">{notification.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 유저 아이콘 */}
|
||||
<div className="min-w-[44px] min-h-[44px] w-11 h-11 bg-muted rounded-xl flex items-center justify-center clean-shadow-sm">
|
||||
<User className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* 로그아웃 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
|
||||
title="로그아웃"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* 햄버거 메뉴 - 맨 우측 */}
|
||||
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center">
|
||||
<Menu className="h-6 w-6" />
|
||||
{/* 유저 프로필 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-[44px] min-h-[44px] p-0 rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-80 p-1 bg-transparent border-none">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>메뉴</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Sidebar
|
||||
menuItems={menuItems}
|
||||
activeMenu={activeMenu}
|
||||
expandedMenus={expandedMenus}
|
||||
sidebarCollapsed={false}
|
||||
isMobile={true}
|
||||
onMenuClick={handleMenuClick}
|
||||
onToggleSubmenu={toggleSubmenu}
|
||||
onCloseMobileSidebar={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0 overflow-hidden">
|
||||
{/* 사용자 정보 헤더 */}
|
||||
<div className="bg-muted px-4 py-4 flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{userName}</p>
|
||||
<p className="text-sm text-muted-foreground">{userPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출근하기 버튼 */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => router.push('/hr/attendance')}
|
||||
className="w-full h-10 bg-blue-500 hover:bg-blue-600 text-white rounded-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
출근하기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 회사 선택 (목업) */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<p className="text-xs text-muted-foreground mb-1">회사 선택</p>
|
||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||
<SelectTrigger className="w-full h-9 text-sm">
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_COMPANIES.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테마 선택 */}
|
||||
<div className="px-2 py-3">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')} className="rounded-lg">
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
일반모드
|
||||
{theme === 'light' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')} className="rounded-lg">
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
다크모드
|
||||
{theme === 'dark' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('senior')} className="rounded-lg">
|
||||
<Accessibility className="mr-2 h-4 w-4" />
|
||||
시니어모드
|
||||
{theme === 'senior' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="my-2 border-t border-border" />
|
||||
|
||||
{/* 로그아웃 */}
|
||||
<DropdownMenuItem onClick={handleLogout} className="rounded-lg text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
로그아웃
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 모바일 콘텐츠 */}
|
||||
<main className="flex-1 overflow-auto px-3">
|
||||
<main className="flex-1 overflow-y-auto px-3 overscroll-contain touch-pan-y" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
@@ -476,52 +596,139 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
<span className="hidden xl:inline">품질인정심사</span>
|
||||
</Button>
|
||||
|
||||
{/* 테마 선택 - React 프로젝트 스타일 */}
|
||||
{/* 회사 선택 셀렉트 박스 (목업) */}
|
||||
<div className="hidden md:flex items-center">
|
||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||
<SelectTrigger className="w-40 h-10 rounded-xl border-border/50 bg-background/50 hover:bg-accent transition-all duration-200">
|
||||
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_COMPANIES.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 알림 버튼 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="p-3 rounded-xl hover:bg-accent transition-all duration-200">
|
||||
{theme === 'light' && <Sun className="h-5 w-5" />}
|
||||
{theme === 'dark' && <Moon className="h-5 w-5" />}
|
||||
{theme === 'senior' && <Accessibility className="h-5 w-5" />}
|
||||
<Button variant="ghost" size="sm" className="p-1 rounded-xl hover:bg-accent transition-all duration-200 relative">
|
||||
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center">
|
||||
<Bell className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
{/* 알림 있을 때 빨간 점 */}
|
||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
||||
<span className="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
일반모드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
다크모드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('senior')}>
|
||||
<Accessibility className="mr-2 h-4 w-4" />
|
||||
시니어모드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent align="end" className="w-96 p-0">
|
||||
{/* 알림 리스트 */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{MOCK_NOTIFICATIONS.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-start gap-3 p-4 border-b border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
{/* 이미지 플레이스홀더 */}
|
||||
<div className="flex-shrink-0 w-16 h-16 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
|
||||
IMG
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">{notification.category}</span>
|
||||
{notification.isNew && (
|
||||
<span className="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
||||
N
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-foreground font-medium mt-1 truncate">{notification.title}</p>
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<span className="flex-shrink-0 text-muted-foreground text-sm">{notification.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 유저 프로필 */}
|
||||
<div className="hidden lg:flex items-center space-x-3 pl-3 border-l border-border/30">
|
||||
<div className="w-11 h-11 bg-primary/10 rounded-xl flex items-center justify-center clean-shadow-sm">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-sm text-left">
|
||||
<p className="font-bold text-foreground text-base">{userName}</p>
|
||||
<p className="text-muted-foreground text-sm">{userPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 유저 프로필 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="hidden lg:flex items-center space-x-3 pl-3 border-l border-border/30 h-auto py-2 px-3 rounded-xl hover:bg-accent transition-all duration-200">
|
||||
<div className="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-sm text-left">
|
||||
<p className="font-bold text-foreground text-base">{userName}</p>
|
||||
<p className="text-muted-foreground text-sm">{userPosition}</p>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0 overflow-hidden">
|
||||
{/* 사용자 정보 헤더 */}
|
||||
<div className="bg-muted px-4 py-4 flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{userName}</p>
|
||||
<p className="text-sm text-muted-foreground">{userPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그아웃 버튼 - 아이콘 형태 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="p-3 rounded-xl hover:bg-accent transition-all duration-200"
|
||||
title="로그아웃"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
{/* 회사 선택 (목업) */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<p className="text-xs text-muted-foreground mb-1">회사 선택</p>
|
||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||
<SelectTrigger className="w-full h-9 text-sm">
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_COMPANIES.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테마 선택 */}
|
||||
<div className="px-2 py-3">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')} className="rounded-lg">
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
일반모드
|
||||
{theme === 'light' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')} className="rounded-lg">
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
다크모드
|
||||
{theme === 'dark' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('senior')} className="rounded-lg">
|
||||
<Accessibility className="mr-2 h-4 w-4" />
|
||||
시니어모드
|
||||
{theme === 'senior' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="my-2 border-t border-border" />
|
||||
|
||||
{/* 로그아웃 */}
|
||||
<DropdownMenuItem onClick={handleLogout} className="rounded-lg text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
로그아웃
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect';
|
||||
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
||||
import { refreshAccessToken } from './refresh-token';
|
||||
|
||||
@@ -175,17 +176,8 @@ export async function serverFetch(
|
||||
|
||||
return { response, error: null };
|
||||
} catch (error) {
|
||||
// Next.js 15: redirect()는 digest 프로퍼티를 가진 에러를 throw
|
||||
// 이 에러는 다시 throw해서 Next.js가 처리하도록 해야 함
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'digest' in error &&
|
||||
typeof (error as { digest: unknown }).digest === 'string' &&
|
||||
(error as { digest: string }).digest.startsWith('NEXT_REDIRECT')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함
|
||||
if (isRedirectError(error)) throw error;
|
||||
console.error(`[serverFetch] Network error: ${url}`, error);
|
||||
return {
|
||||
response: null,
|
||||
|
||||
@@ -99,18 +99,25 @@ export async function refreshAccessToken(
|
||||
): Promise<RefreshResult> {
|
||||
const now = Date.now();
|
||||
|
||||
// 1. 캐시된 결과가 유효하면 즉시 반환
|
||||
// 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) {
|
||||
// 2. 진행 중인 refresh가 있고, 아직 결과가 없으면 기다림 (실패한 결과는 캐시 안 함)
|
||||
if (refreshCache.promise && !refreshCache.result && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
|
||||
console.log(`🔵 [${caller}] Waiting for ongoing refresh...`);
|
||||
return refreshCache.promise;
|
||||
}
|
||||
|
||||
// 2-1. 이전 refresh가 실패했으면 캐시 초기화
|
||||
if (refreshCache.result && !refreshCache.result.success) {
|
||||
console.log(`🔄 [${caller}] Previous refresh failed, clearing cache...`);
|
||||
refreshCache.promise = null;
|
||||
refreshCache.result = null;
|
||||
}
|
||||
|
||||
// 3. 새 refresh 시작
|
||||
console.log(`🔄 [${caller}] Starting new refresh request...`);
|
||||
refreshCache.timestamp = now;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user