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:
byeongcheolryu
2026-01-08 17:15:42 +09:00
parent 387672b5b2
commit 29e7b41615
92 changed files with 7695 additions and 409 deletions

View 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 레벨 권한 체크 활성화
---
*이 문서는 권한 시스템 구현 시 참고용으로 작성되었습니다.*

View 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개 섹션) | 완료 |

View File

@@ -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 기반 설정 영속화
- 대시보드 섹션 조건부 렌더링 적용

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

View File

@@ -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줄 절감) |

View File

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

View File

@@ -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 마이그레이션 | 예정 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,6 @@ export interface VendorReceivables {
carryForwardBalance: number; // 이월잔액
monthLabels: string[]; // 동적 월 레이블 (ex: ['25.02', '25.03', ...])
categories: CategoryData[];
memo?: string; // 거래처별 메모 (단일 텍스트)
}
/**

View File

@@ -13,6 +13,8 @@
'use server';
import { isRedirectError } from 'next/dist/client/components/redirect';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type {
SalesRecord,

View File

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

View File

@@ -12,6 +12,8 @@
'use server';
import { isRedirectError } from 'next/dist/client/components/redirect';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type {
Vendor,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,2 @@
export { CEODashboard } from './CEODashboard';
export * from './types';

View File

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

View File

@@ -0,0 +1 @@
export { ScheduleDetailModal } from './ScheduleDetailModal';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -13,6 +13,8 @@
'use server';
import { isRedirectError } from 'next/dist/client/components/redirect';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type {
AttendanceRecord,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 : '권한 초기화 실패' };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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