feat(WEB): 회계/HR/주문관리 모듈 개선 및 알림설정 리팩토링

- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선
- HR: 직원 관리 및 출퇴근 설정 기능 수정
- 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables)
- 알림설정: 컴포넌트 구조 단순화 및 리팩토링
- 캘린더: 헤더 및 일정 타입 개선
- 출고관리: 액션 및 타입 정의 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-06 09:58:10 +09:00
parent 386cd30bc0
commit a938da9e22
76 changed files with 2899 additions and 2073 deletions

View File

@@ -1,171 +1,239 @@
# 문서 모달 공통 컴포넌트 설계 요구사항
> Last Updated: 2026-01-06
## 현황 분석
### 기존 문서 모달 목록 (6개)
### 전체 문서 모달 목록 (10개)
#### A. juil 비즈니스 모달 (프린트 중심)
| 컴포넌트 | 용도 | 헤더 구성 | 결재라인 |
|---------|------|----------|---------|
| ProcessWorkLogPreviewModal | 공정 작업일지 | 로고 + 제목 + 결재 | 3열 (자체 구현) |
| WorkLogModal | 생산 작업일지 | 로고 + 제목 + 결재 | 3열 (자체 구현) |
| EstimateDocumentModal | 견적서 | 제목 + 결재 | 3열 (자체 구현) |
| ContractDocumentModal | 계약서 | PDF iframe | 없음 |
| HandoverReportDocumentModal | 인수인계보고서 | 결재 먼저 | 4열 (자체 구현) |
| **OrderDocumentModal (juil)** | 🆕 발주서 | 제목만 | 없음 |
#### B. 수주 문서 모달
| 컴포넌트 | 용도 | 헤더 구성 |
|---------|------|----------|
| ProcessWorkLogPreviewModal | 공정 작업일지 | 로고 + 제목 + 결재(3열) |
| WorkLogModal | 생산 작업일지 | 로고 + 제목 + 결재(3열) |
| EstimateDocumentModal | 견적서 | 제목 + 결재(3열) |
| OrderDocumentModal | 수주문서(3종) | 제목만 |
| ContractDocumentModal | 계약서 | PDF iframe |
| HandoverReportDocumentModal | 인수인계보고서 | 결재(4열) 먼저 |
| OrderDocumentModal (orders) | 수주문서 3종 | 제목만 (분기) |
### 공통 패턴 ✅
#### C. 전자결재 문서 (approval)
| 컴포넌트 | 용도 | 결재라인 |
|---------|------|---------|
| ProposalDocument | 품의서 | ⭐ **ApprovalLineBox** 사용 |
| ExpenseReportDocument | 지출결의서 | ⭐ **ApprovalLineBox** 사용 |
| ExpenseEstimateDocument | 지출예상내역서 | ⭐ **ApprovalLineBox** 사용 |
---
## ⭐ 기존 공통 컴포넌트 발견
### ApprovalLineBox (이미 존재!)
**위치**: `src/components/approval/DocumentDetail/ApprovalLineBox.tsx`
```tsx
interface ApprovalLineBoxProps {
drafter: Approver; // 작성자
approvers: Approver[]; // 결재자 배열 (동적 열 개수)
}
interface Approver {
id: string;
name: string;
position: string;
department: string;
status: 'pending' | 'approved' | 'rejected' | 'none';
approvedAt?: string;
}
```
**특징**:
- ✅ 동적 열 개수 지원 (approvers 배열 길이에 따라)
- ✅ 상태 아이콘 표시 (승인/반려/대기)
- ✅ 구분/이름/부서 3행 구조
- ⚠️ 현재 approval 문서에서만 사용 중
### 문제점
- juil 문서들은 **자체 결재라인 구현** (코드 중복)
- 각 문서마다 결재라인 구조가 미묘하게 다름
- 작업일지: 작성/검토/승인 + 날짜행
- 견적서: 작성/승인 (2열)
- 인수인계: 작성/검토/승인/승인 (4열)
---
## 공통 패턴 분석
### ✅ 완전히 동일한 패턴
```
1. 모달 프레임: Radix UI Dialog
2. 인쇄 처리: print-hidden + print-area 클래스
3. 인쇄 유틸: printArea() 함수 (lib/print-utils.ts)
4. 용지 크기: max-w-[210mm] (A4 기준)
5. 레이아웃: 고정 헤더 + 스크롤 문서 영역
5. 레이아웃: 고정 헤더 + 버튼 영역 + 스크롤 문서 영역
6. 모달 크기: max-w-[95vw] md:max-w-[800px] lg:max-w-[900px]
```
### 변동 영역 🔄
```
1. 문서 헤더 (가장 복잡)
- 결재라인: 3열/4열/없음
- 로고: 있음/없음, 좌측/우측
- 제목: 중앙/좌측, 부제목 유무
- 문서번호/날짜: 위치 다양
### 🔄 변동이 심한 영역
2. 버튼 영역
- 인쇄만/수정+인쇄/상신+인쇄+삭제 등
#### 1. 문서 헤더 레이아웃
| 유형 | 문서 | 구조 |
|------|------|------|
| 3열 | 작업일지 | `[로고] [제목+코드] [결재]` |
| 2열 | 견적서, 품의서 | `[제목+번호] [결재]` |
| 1열+우측 | 인수인계 | `[결재 먼저] + [기본정보]` |
| 1열 중앙 | 발주서, 수주문서 | `[제목 중앙]` |
3. 본문 테이블
- 컬럼 구성, 합계행, 소계 등
```
#### 2. 결재라인 구성
| 문서 | 열 구조 | 행 구조 |
|------|---------|---------|
| 작업일지 | 작성/검토/승인 | 구분/이름/부서/날짜 |
| 견적서 | 작성/승인 | 구분/이름/부서 |
| 인수인계 | 작성/검토/승인/승인 | 구분/이름/부서 |
| 전자결재 | **동적** (ApprovalLineBox) | 구분/이름/부서 |
#### 3. 버튼 영역
| 문서 | 버튼 구성 |
|------|----------|
| 견적서 | 수정, 상신, 인쇄 |
| 발주서 | 수정, 삭제, 인쇄 |
| 전자결재 | 수정, 복사, 승인, 반려, 상신 |
---
## 공통 컴포넌트 제안
## 공통 컴포넌트 제안 (수정)
### 1. PrintableDocumentModal (Base)
모달 프레임 + 인쇄 기능만 담당
모달 프레임 + 인쇄 기능만 담당 (변경 없음)
```tsx
interface PrintableDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string; // 모달 타이틀
width?: 'sm' | 'md' | 'lg'; // 800px | 900px | 1000px
actions?: ReactNode; // 버튼 영역 (수정/상신/삭제 등)
children: ReactNode; // 문서 본문
title: string;
width?: 'sm' | 'md' | 'lg';
actions?: ReactNode; // 버튼 영역
children: ReactNode; // 문서 본문
}
```
**제공 기능**:
- Dialog 래퍼 + print-hidden 헤더
- 인쇄 버튼 (기본 제공)
- print-area 문서 영역 + A4 스타일
### 2. DocumentHeader (Composable)
문서 헤더 조합용 컴포넌트들
### 2. ApprovalLine (확장)
**기존 ApprovalLineBox 확장 또는 새로 통합**
```tsx
// 결재라인 컴포넌트
<ApprovalLine
columns={3} // 3열 또는 4열
approvers={[
{ role: '작성', name: '홍길동', date: '2026-01-02' },
{ role: '검토', name: '김철수', date: '' },
{ role: '승인', name: '박영희', date: '' }
]}
/>
interface ApprovalLineProps {
// 방법 1: 단순 열 지정
columns?: 2 | 3 | 4;
approvers?: Array<{
role: string; // '작성' | '검토' | '승인'
name: string;
department?: string;
date?: string;
status?: 'pending' | 'approved' | 'rejected';
}>;
// 문서 타이틀 컴포넌트
<DocumentTitle
title="견적서"
subtitle="QUOTATION" // 선택
code="EST-2026-001" // 문서번호 (선택)
date="2026-01-02" // 작성일자 (선택)
/>
// 방법 2: 기존 ApprovalLineBox 호환
drafter?: Approver;
dynamicApprovers?: Approver[];
// 회사 로고 컴포넌트
<CompanyLogo
type="KD" | "정동기업" | "custom"
customSrc?: string
/>
// 옵션
showDateRow?: boolean; // 날짜행 표시 여부
showStatusIcon?: boolean; // 상태 아이콘 표시 여부
}
```
### 3. 헤더 레이아웃 프리셋
### 3. DocumentHeaderLayout (프리셋)
```tsx
// 3열 레이아웃: 로고 | 타이틀 | 결재
type HeaderVariant =
| 'three-column' // [로고] [제목] [결재]
| 'two-column' // [제목+번호] [결재]
| 'single-center' // [제목 중앙]
| 'approval-first' // [결재] + [정보 테이블]
<DocumentHeaderLayout variant="three-column">
<CompanyLogo type="KD" />
<DocumentTitle title="작업일지" />
<DocumentTitle title="작업일지" code="WL-001" />
<ApprovalLine columns={3} approvers={...} />
</DocumentHeaderLayout>
// 2열 레이아웃: 타이틀 | 결재
<DocumentHeaderLayout variant="two-column">
<DocumentTitle title="견적서" code="EST-001" />
<ApprovalLine columns={3} approvers={...} />
</DocumentHeaderLayout>
// 1열 레이아웃: 타이틀만 (중앙)
<DocumentHeaderLayout variant="single">
<DocumentTitle title="거래명세서" centered />
</DocumentHeaderLayout>
```
---
## 컴포넌트 구조 제안
## 컴포넌트 구조 제안 (수정)
```
src/components/common/document/
├── PrintableDocumentModal.tsx # 기본 모달 프레임
├── DocumentHeader/
│ ├── index.tsx # 헤더 레이아웃
│ ├── ApprovalLine.tsx # 결재라인
│ ├── index.tsx # 헤더 레이아웃 프리셋
│ ├── DocumentTitle.tsx # 문서 타이틀
│ └── CompanyLogo.tsx # 회사 로고
├── ApprovalLine/
│ ├── index.tsx # 통합 결재라인 (★ 핵심)
│ └── ApprovalLineBox.tsx # 기존 컴포넌트 이동/확장
├── DocumentTable/
│ ├── index.tsx # 기본 문서 테이블
│ ├── SummaryRow.tsx # 합계행
│ └── InfoGrid.tsx # 정보 그리드 (2×4 등)
│ ├── InfoGrid.tsx # 정보 그리드 (2×4 등)
│ └── SummaryRow.tsx # 합계행
└── index.ts # 배럴 export
```
---
## 마이그레이션 우선순위
## 마이그레이션 전략
| 우선순위 | 컴포넌트 | 이유 |
|---------|---------|------|
| 1 | WorkLogModal 계열 (2개) | 구조 동일, 가장 표준적 |
| 2 | EstimateDocumentModal | 결재라인 + 복잡한 테이블 |
| 3 | OrderDocumentModal | 3종 문서 분기 포함 |
| 4 | HandoverReportDocumentModal | 다른 헤더 구성 |
| 5 | ContractDocumentModal | PDF 특수 케이스 |
### Phase 1: ApprovalLine 통합 (우선)
1. 기존 `ApprovalLineBox``common/document/ApprovalLine/`로 이동
2. columns 기반 간단 모드 추가
3. showDateRow, showStatusIcon 옵션 추가
### Phase 2: PrintableDocumentModal 생성
1. 모달 프레임 공통화
2. print-hidden/print-area 자동 적용
3. 버튼 영역 슬롯 제공
### Phase 3: 기존 모달 리팩토링
| 순서 | 모달 | 작업량 |
|------|------|-------|
| 1 | WorkLogModal 계열 | 구조 동일, 리팩토링 쉬움 |
| 2 | EstimateDocumentModal | 결재라인 교체 |
| 3 | 전자결재 문서들 | ApprovalLineBox 경로 변경만 |
| 4 | OrderDocumentModal (juil) | 결재라인 없음, 프레임만 적용 |
| 5 | HandoverReportDocumentModal | 4열 결재라인 |
---
## 결정 필요 사항
### Q1. 헤더 구성 접근법
- **A) 프리셋 기반**: 3가지 레이아웃 프리셋으로 제한
- **B) 완전 조합형**: 블록 컴포넌트 자유 조합
### Q1. ApprovalLine 통합 방식
- **A) 확장**: 기존 ApprovalLineBox에 옵션 추가
- **B) 새로 작성**: columns 기반 단순 버전 + 기존 호환 어댑터
### Q2. 결재라인 데이터
- **A) Props 직접 전달**: 사용처에서 데이터 구성
- **B) Context/Hook**: 문서별 결재 설정 중앙 관리
### Q2. 위치 결정
- **A) common/document/**: 문서 전용 공통 컴포넌트
- **B) approval/에서 re-export**: 기존 위치 유지, 공용 export
### Q3. 테이블 공통화 범위
- **A) 스타일만 공통화**: 테이블 래퍼 + CSS
- **B) 구조까지 공통화**: columns 정의 + 렌더링 로직
### Q3. 날짜행 처리
- **A) 옵션화**: `showDateRow={true}`
- **B) 별도 컴포넌트**: `ApprovalLineWithDate`
---
## 예상 작업량
## 예상 작업량 (수정)
| 단계 | 내용 | 예상 파일 수 |
|------|------|-------------|
| 1 | 공통 컴포넌트 생성 | 8개 |
| 2 | 기존 모달 리팩토링 | 6개 |
| 3 | 테스트 및 검증 | - |
| 단계 | 내용 | 파일 수 |
|------|------|--------|
| 1 | ApprovalLine 통합 | 3개 |
| 2 | PrintableDocumentModal | 2개 |
| 3 | DocumentHeader 컴포넌트 | 3개 |
| 4 | 기존 모달 리팩토링 | 10개 |
**총 예상**: ~18개 파일 수정/생성
---
@@ -178,4 +246,31 @@ printArea(options?: { title?: string; styles?: string })
- `.print-area` 클래스 요소를 새 창에서 인쇄
- A4 용지 설정 자동 적용
- 기존 스타일시트 자동 로드
- 기존 스타일시트 자동 로드
---
## 관련 파일 경로
```
문서 모달 관련 파일들:
src/components/
├── process-management/
│ └── ProcessWorkLogPreviewModal.tsx
├── production/WorkerScreen/
│ └── WorkLogModal.tsx
├── orders/documents/
│ └── OrderDocumentModal.tsx (수주)
├── approval/DocumentDetail/
│ ├── ApprovalLineBox.tsx ⭐ 기존 공통
│ ├── ProposalDocument.tsx
│ ├── ExpenseReportDocument.tsx
│ ├── ExpenseEstimateDocument.tsx
│ └── types.ts
└── business/juil/
├── estimates/modals/EstimateDocumentModal.tsx
├── contract/modals/ContractDocumentModal.tsx
├── handover-report/modals/HandoverReportDocumentModal.tsx
└── order-management/modals/OrderDocumentModal.tsx 🆕
```

View File

@@ -0,0 +1,60 @@
# StatCards 컴포넌트 레이아웃 변경
## 변경일
2026-01-05
## 변경 파일
`/src/components/organisms/StatCards.tsx`
## 변경 내용
### Before (flex 기반)
```tsx
<div className="flex flex-col sm:flex-row gap-3 md:gap-4">
```
- 모바일: 세로 1열
- SM 이상: 가로 한 줄로 모든 카드 표시 (`flex-1`)
### After (grid 기반)
```tsx
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
```
- 모바일: 2열 그리드
- SM 이상: 3열 그리드
## 변경 사유
### 문제점
- 급여관리 등 카드가 6개인 페이지에서 한 줄에 모든 카드가 들어가면 각 카드가 너무 좁아짐
- PC 화면에서도 카드 내용이 빽빽하게 보여 가독성 저하
### 해결
- grid 기반 레이아웃으로 변경하여 PC에서 3개씩 2줄로 표시
- 각 카드가 충분한 너비를 확보하여 가독성 향상
- 카드 개수에 따라 자연스럽게 줄바꿈
## 영향 범위
`StatCards` 컴포넌트는 공통 컴포넌트로, 다음 템플릿에서 사용:
- `IntegratedListTemplateV2`
- `ListPageTemplate`
해당 템플릿을 사용하는 모든 페이지에 적용됨.
## 레이아웃 예시
### 카드 6개 (급여관리)
```
| 카드1 | 카드2 | 카드3 |
| 카드4 | 카드5 | 카드6 |
```
### 카드 4개
```
| 카드1 | 카드2 | 카드3 |
| 카드4 | | |
```
### 카드 3개
```
| 카드1 | 카드2 | 카드3 |
```

View File

@@ -0,0 +1,292 @@
# OrderDetailForm.tsx 분리 계획서
**생성일**: 2026-01-05
**현재 파일 크기**: 1,273줄
**목표**: 유지보수성 향상을 위한 컴포넌트 분리
---
## 현재 파일 구조 분석
| 영역 | 라인 | 비율 | 내용 |
|------|------|------|------|
| Import & Types | 1-69 | 5% | 의존성 및 타입 import |
| Props Interface | 70-74 | 0.5% | 컴포넌트 props |
| State & Hooks | 76-113 | 3% | 상태 관리 (12개 useState) |
| Handlers | 114-433 | 25% | 핸들러 함수들 (20+개) |
| JSX Render | 435-1271 | 66% | UI 렌더링 |
### 주요 핸들러 분류 (114-433줄)
- **Navigation**: handleBack, handleEdit, handleCancel (114-125)
- **Form Field**: handleFieldChange (127-133)
- **CRUD Operations**: handleSave, handleDelete, handleDuplicate (135-199)
- **Category Operations**: handleAddCategory, handleDeleteCategory, handleCategoryChange (206-247)
- **Item Operations**: handleAddItems, handleDeleteSelectedItems, handleDeleteAllItems, handleItemChange (249-327)
- **Selection**: handleToggleSelection, handleToggleSelectAll (330-357)
- **Calendar**: handleCalendarDateClick, handleCalendarMonthChange (359-385)
### 주요 JSX 영역 (435-1271줄)
- **발주 정보 Card**: 447-559 (112줄)
- **계약 정보 Card**: 561-694 (133줄)
- **발주 스케줄 Calendar**: 696-715 (19줄)
- **발주 상세 테이블**: 717-1172 (455줄) ⚠️ **가장 큰 부분**
- **카테고리 추가 버튼**: 1174-1182 (8줄)
- **비고 Card**: 1184-1198 (14줄)
- **Dialogs**: 1201-1261 (60줄)
- **Document Modal**: 1263-1270 (7줄)
---
## 분리 계획
### Phase 1: 커스텀 훅 분리
**파일**: `hooks/useOrderDetailForm.ts`
**예상 크기**: ~250줄
```typescript
// 추출할 내용
- formData 상태 관리
- selectedItems, addCounts, categoryFilters 상태
- calendarDate, selectedCalendarDate 상태
- 모든 핸들러 함수들
- calendarEvents useMemo
```
**장점**:
- 비즈니스 로직과 UI 분리
- 테스트 용이성 향상
- 재사용 가능
---
### Phase 2: 카드 컴포넌트 분리
#### 2-1. `cards/OrderInfoCard.tsx`
**예상 크기**: ~120줄
```typescript
interface OrderInfoCardProps {
formData: OrderDetailFormData;
isViewMode: boolean;
onFieldChange: (field: keyof OrderDetailFormData, value: any) => void;
}
```
**포함 내용**: 발주번호, 발주일, 구분, 상태, 발주담당자, 화물도착지
---
#### 2-2. `cards/ContractInfoCard.tsx`
**예상 크기**: ~150줄
```typescript
interface ContractInfoCardProps {
formData: OrderDetailFormData;
isViewMode: boolean;
isEditMode: boolean;
onFieldChange: (field: keyof OrderDetailFormData, value: any) => void;
}
```
**포함 내용**: 거래처명, 현장명, 계약번호, 공사PM, 공사담당자
---
#### 2-3. `cards/OrderScheduleCard.tsx`
**예상 크기**: ~50줄
```typescript
interface OrderScheduleCardProps {
events: ScheduleEvent[];
currentDate: Date;
selectedDate: Date | null;
onDateClick: (date: Date) => void;
onMonthChange: (date: Date) => void;
}
```
**포함 내용**: ScheduleCalendar 래핑
---
#### 2-4. `cards/OrderMemoCard.tsx`
**예상 크기**: ~40줄
```typescript
interface OrderMemoCardProps {
memo: string;
isViewMode: boolean;
onMemoChange: (value: string) => void;
}
```
**포함 내용**: 비고 Textarea
---
### Phase 3: 테이블 컴포넌트 분리 (가장 중요)
#### 3-1. `tables/OrderDetailItemTable.tsx`
**예상 크기**: ~350줄
```typescript
interface OrderDetailItemTableProps {
category: OrderDetailCategory;
isEditMode: boolean;
isViewMode: boolean;
selectedItems: Set<string>;
addCount: number;
onAddCountChange: (count: number) => void;
onAddItems: (count: number) => void;
onDeleteSelectedItems: () => void;
onDeleteAllItems: () => void;
onCategoryChange: (field: keyof OrderDetailCategory, value: string) => void;
onItemChange: (itemId: string, field: keyof OrderDetailItem, value: any) => void;
onToggleSelection: (itemId: string) => void;
onToggleSelectAll: () => void;
}
```
**포함 내용**:
- 카드 헤더 (왼쪽: 발주 상세/N건 선택/삭제, 오른쪽: 숫자/추가/카테고리/🗑️)
- 테이블 전체 (TableHeader + TableBody)
- 합계 행
---
#### 3-2. `tables/OrderDetailItemRow.tsx` (선택적)
**예상 크기**: ~150줄
```typescript
interface OrderDetailItemRowProps {
item: OrderDetailItem;
index: number;
isEditMode: boolean;
isSelected: boolean;
onItemChange: (field: keyof OrderDetailItem, value: any) => void;
onToggleSelection: () => void;
}
```
**포함 내용**: 단일 테이블 행 렌더링
---
### Phase 4: 다이얼로그 분리
#### 4-1. `dialogs/OrderDialogs.tsx`
**예상 크기**: ~80줄
```typescript
interface OrderDialogsProps {
// 저장 다이얼로그
showSaveDialog: boolean;
onSaveDialogChange: (open: boolean) => void;
onConfirmSave: () => void;
// 삭제 다이얼로그
showDeleteDialog: boolean;
onDeleteDialogChange: (open: boolean) => void;
onConfirmDelete: () => void;
// 카테고리 삭제 다이얼로그
showCategoryDeleteDialog: string | null;
onCategoryDeleteDialogChange: (categoryId: string | null) => void;
onConfirmDeleteCategory: () => void;
// 공통
isLoading: boolean;
}
```
---
## 분리 후 예상 구조
```
src/components/business/juil/order-management/
├── OrderDetailForm.tsx (~200줄, 메인 컴포넌트)
├── hooks/
│ └── useOrderDetailForm.ts (~250줄, 비즈니스 로직)
├── cards/
│ ├── OrderInfoCard.tsx (~120줄)
│ ├── ContractInfoCard.tsx (~150줄)
│ ├── OrderScheduleCard.tsx (~50줄)
│ └── OrderMemoCard.tsx (~40줄)
├── tables/
│ ├── OrderDetailItemTable.tsx (~350줄)
│ └── OrderDetailItemRow.tsx (~150줄, 선택적)
├── dialogs/
│ └── OrderDialogs.tsx (~80줄)
├── modals/
│ └── OrderDocumentModal.tsx (기존)
├── actions.ts (기존)
└── types.ts (기존)
```
---
## 분리 전후 비교
| 지표 | Before | After |
|------|--------|-------|
| 메인 파일 크기 | 1,273줄 | ~200줄 |
| 가장 큰 파일 | 1,273줄 | ~350줄 |
| 파일 개수 | 1 | 8-9 |
| 테스트 용이성 | 낮음 | 높음 |
| 재사용성 | 낮음 | 중간 |
---
## 실행 체크리스트
### Phase 1: 커스텀 훅 분리
- [ ] `hooks/useOrderDetailForm.ts` 생성
- [ ] 상태 변수들 이동
- [ ] 핸들러 함수들 이동
- [ ] useMemo 이동
- [ ] OrderDetailForm.tsx에서 훅 사용
### Phase 2: 카드 컴포넌트 분리
- [ ] `cards/OrderInfoCard.tsx` 생성
- [ ] `cards/ContractInfoCard.tsx` 생성
- [ ] `cards/OrderScheduleCard.tsx` 생성
- [ ] `cards/OrderMemoCard.tsx` 생성
- [ ] OrderDetailForm.tsx에서 import 및 사용
### Phase 3: 테이블 컴포넌트 분리
- [ ] `tables/OrderDetailItemTable.tsx` 생성
- [ ] `tables/OrderDetailItemRow.tsx` 생성 (선택적)
- [ ] OrderDetailForm.tsx에서 import 및 사용
### Phase 4: 다이얼로그 분리
- [ ] `dialogs/OrderDialogs.tsx` 생성
- [ ] OrderDetailForm.tsx에서 import 및 사용
### Phase 5: 최종 검증
- [ ] TypeScript 타입 오류 없음
- [ ] ESLint 경고 없음
- [ ] 빌드 성공
- [ ] 기능 테스트 (view/edit 모드)
- [ ] 불필요한 import 제거
---
## 우선순위 권장
1. **Phase 1 (Hook)** + **Phase 3 (Table)** 먼저 진행
- 가장 큰 효과 (전체 코드의 ~60% 분리)
- 테이블이 455줄로 가장 큼
2. Phase 2 (Cards) 진행
- 추가 ~360줄 분리
3. Phase 4 (Dialogs) 진행
- 마무리 정리
---
## 주의사항
- **타입 export**: 새 컴포넌트에서 사용할 타입들 types.ts에서 export 확인
- **props drilling**: 너무 깊어지면 Context 고려
- **테스트**: 분리 후 view/edit 모드 모두 테스트 필수
- **점진적 진행**: 한 번에 모든 분리보다 단계별 진행 권장

View File

@@ -66,7 +66,7 @@ export default function MobileAttendancePage() {
const fetchTodayAttendance = async () => {
try {
const result = await getTodayAttendance(userId);
const result = await getTodayAttendance();
if (result.success && result.data) {
// 이미 출근한 경우
if (result.data.checkIn) {

View File

@@ -21,13 +21,18 @@ export default function EmployeeEditPage() {
setIsLoading(true);
try {
const data = await getEmployeeById(id);
// __authError 처리
if (data && '__authError' in data) {
router.push('/ko/login');
return;
}
setEmployee(data);
} catch (error) {
console.error('[EmployeeEditPage] fetchEmployee error:', error);
} finally {
setIsLoading(false);
}
}, [params.id]);
}, [params.id, router]);
useEffect(() => {
fetchEmployee();

View File

@@ -33,13 +33,18 @@ export default function EmployeeDetailPage() {
setIsLoading(true);
try {
const data = await getEmployeeById(id);
// __authError 처리
if (data && '__authError' in data) {
router.push('/ko/login');
return;
}
setEmployee(data);
} catch (error) {
console.error('[EmployeeDetailPage] fetchEmployee error:', error);
} finally {
setIsLoading(false);
}
}, [params.id]);
}, [params.id, router]);
useEffect(() => {
fetchEmployee();

View File

@@ -7,102 +7,122 @@ import type { Account } from '@/components/settings/AccountManagement/types';
// Mock 데이터 (API 연동 전 임시)
const mockAccounts: Account[] = [
{
id: 'account-1',
id: 1,
bankCode: 'shinhan',
bankName: '신한은행',
accountNumber: '1234-1234-1234-1234',
accountName: '운영계좌 1',
accountHolder: '예금주1',
status: 'active',
isPrimary: true,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-2',
id: 2,
bankCode: 'kb',
bankName: 'KB국민은행',
accountNumber: '1234-1234-1234-1235',
accountName: '운영계좌 2',
accountHolder: '예금주2',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-3',
id: 3,
bankCode: 'woori',
bankName: '우리은행',
accountNumber: '1234-1234-1234-1236',
accountName: '운영계좌 3',
accountHolder: '예금주3',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-4',
id: 4,
bankCode: 'hana',
bankName: '하나은행',
accountNumber: '1234-1234-1234-1237',
accountName: '운영계좌 4',
accountHolder: '예금주4',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-5',
id: 5,
bankCode: 'nh',
bankName: 'NH농협은행',
accountNumber: '1234-1234-1234-1238',
accountName: '운영계좌 5',
accountHolder: '예금주5',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-6',
id: 6,
bankCode: 'ibk',
bankName: 'IBK기업은행',
accountNumber: '1234-1234-1234-1239',
accountName: '운영계좌 6',
accountHolder: '예금주6',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-7',
id: 7,
bankCode: 'shinhan',
bankName: '신한은행',
accountNumber: '1234-1234-1234-1240',
accountName: '운영계좌 7',
accountHolder: '예금주7',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-8',
id: 8,
bankCode: 'kb',
bankName: 'KB국민은행',
accountNumber: '1234-1234-1234-1241',
accountName: '운영계좌 8',
accountHolder: '예금주8',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-9',
id: 9,
bankCode: 'woori',
bankName: '우리은행',
accountNumber: '1234-1234-1234-1242',
accountName: '운영계좌 9',
accountHolder: '예금주9',
status: 'active',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
{
id: 'account-10',
id: 10,
bankCode: 'hana',
bankName: '하나은행',
accountNumber: '1234-1234-1234-1243',
accountName: '운영계좌 10',
accountHolder: '예금주10',
status: 'inactive',
isPrimary: false,
createdAt: '2025-12-19T00:00:00.000Z',
updatedAt: '2025-12-19T00:00:00.000Z',
},
@@ -110,7 +130,7 @@ const mockAccounts: Account[] = [
export default function AccountDetailPage() {
const params = useParams();
const accountId = params.id as string;
const accountId = Number(params.id);
// Mock: 계좌 조회
const account = mockAccounts.find(a => a.id === accountId);

View File

@@ -334,7 +334,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
{isLoading ? '처리중...' : '삭제'}
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
</Button>
</div>
@@ -345,7 +345,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading ? '처리중...' : (isNewMode ? '등록' : '저장')}
</Button>
</div>
@@ -917,7 +917,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
@@ -988,7 +988,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
>
</AlertDialogAction>

View File

@@ -7,6 +7,7 @@ import {
Plus,
X,
Loader2,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -274,6 +275,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -283,7 +285,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
@@ -295,7 +297,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
>
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isNewMode ? '등록' : '저장'}

View File

@@ -422,7 +422,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
<Save className="h-4 w-4 mr-2" />
</Button>

View File

@@ -472,7 +472,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
</Select>
{/* 저장 버튼 */}
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
<Save className="h-4 w-4 mr-2" />
{isSaving ? '저장중...' : '저장'}
</Button>

View File

@@ -364,7 +364,7 @@ export function CardTransactionInquiry({
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
@@ -487,7 +487,7 @@ export function CardTransactionInquiry({
</Button>
<Button
onClick={handleConfirmSaveAccountSubject}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isSaving}
>
{isSaving ? (

View File

@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Banknote,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -180,6 +181,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -190,7 +192,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
@@ -202,7 +204,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
</Button>
<Button
onClick={handleSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}

View File

@@ -495,7 +495,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
@@ -584,7 +584,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
</Button>
<Button
onClick={handleConfirmSaveAccountSubject}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
>
</Button>

View File

@@ -34,7 +34,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { FileText, Plus, X, Eye, Receipt } from 'lucide-react';
import { FileText, Plus, X, Eye, Receipt, List } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
@@ -293,6 +293,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -302,7 +303,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
@@ -312,7 +313,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
{isSaving ? '저장 중...' : isNewMode ? '등록' : '저장'}
</Button>
</>

View File

@@ -539,7 +539,7 @@ export function PurchaseManagement() {
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
@@ -622,7 +622,7 @@ export function PurchaseManagement() {
</Button>
<Button
onClick={handleConfirmSaveAccountSubject}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
>
</Button>

View File

@@ -39,6 +39,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
category: cat.category,
amounts: cat.amounts,
})),
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
};
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from 'react';
import { Download, FileText, Save, Loader2, RefreshCw } from 'lucide-react';
import { useState, useMemo, useCallback, useEffect, useRef, useTransition, Fragment } from '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 { Card, CardContent } from '@/components/ui/card';
@@ -68,6 +68,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
overdueVendorCount: 0,
});
const [isLoading, setIsLoading] = useState(!initialData.length);
const [expandedMemos, setExpandedMemos] = useState<Set<string>>(new Set());
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
@@ -125,6 +126,19 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
));
}, []);
// ===== 메모 펼치기/접기 토글 =====
const toggleMemoExpand = useCallback((vendorId: string) => {
setExpandedMemos(prev => {
const newSet = new Set(prev);
if (newSet.has(vendorId)) {
newSet.delete(vendorId);
} else {
newSet.add(vendorId);
}
return newSet;
});
}, []);
// ===== 엑셀 다운로드 핸들러 =====
const handleExcelDownload = useCallback(async () => {
const result = await exportReceivablesExcel({
@@ -213,8 +227,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
return totals;
}, [filteredData]);
// ===== 카테고리 순서 =====
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable', 'memo'];
// ===== 카테고리 순서 (메모 제외 - 별도 렌더링) =====
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
return (
<PageLayout>
@@ -275,7 +289,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
size="sm"
onClick={handleSave}
disabled={isPending || changedItems.length === 0}
className="bg-orange-500 hover:bg-orange-600 disabled:opacity-50"
className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -345,78 +359,131 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
</TableCell>
</TableRow>
) : (
filteredData.map((vendor) => (
categoryOrder.map((category, catIndex) => {
const categoryData = vendor.categories.find(c => c.category === category);
if (!categoryData) return null;
filteredData.map((vendor) => {
const isOverdueRow = vendor.isOverdue;
const isHighlighted = highlightVendorId === vendor.id;
const rowBgClass = isHighlighted
? 'bg-yellow-100'
: isOverdueRow
? 'bg-red-50'
: 'bg-white';
const isExpanded = expandedMemos.has(vendor.id);
const hasMemo = vendor.memo && vendor.memo.trim().length > 0;
const isOverdueRow = vendor.isOverdue;
const isHighlighted = highlightVendorId === vendor.id;
// 하이라이트 > 연체 > 기본 순으로 배경색 결정
const rowBgClass = isHighlighted
? 'bg-yellow-100'
: isOverdueRow
? 'bg-red-50'
: 'bg-white';
return (
<Fragment key={vendor.id}>
{/* 카테고리 행들 (매출, 입금, 어음, 미수금) */}
{categoryOrder.map((category, catIndex) => {
const categoryData = vendor.categories.find(c => c.category === category);
if (!categoryData) return null;
return (
<TableRow
key={`${vendor.id}-${category}`}
ref={catIndex === 0 && isHighlighted ? highlightRowRef : undefined}
className={`${catIndex === 0 ? 'border-t-2 border-gray-300' : ''} ${isHighlighted ? 'ring-2 ring-yellow-400 ring-inset' : ''}`}
>
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
{catIndex === 0 && (
<TableCell
rowSpan={5}
className={`font-medium border-r border-gray-200 align-top pt-3 sticky left-0 z-10 ${rowBgClass}`}
return (
<TableRow
key={`${vendor.id}-${category}`}
ref={catIndex === 0 && isHighlighted ? highlightRowRef : undefined}
className={`${catIndex === 0 ? 'border-t-2 border-gray-300' : ''} ${isHighlighted ? 'ring-2 ring-yellow-400 ring-inset' : ''}`}
>
<div className="flex flex-col gap-2">
<span className="text-sm">{vendor.vendorName}</span>
<div className="flex items-center gap-2">
<Switch
checked={vendor.isOverdue}
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
className="data-[state=checked]:bg-red-500"
/>
{vendor.isOverdue && (
<span className="text-xs text-red-500 font-medium"></span>
)}
</div>
</div>
</TableCell>
)}
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
{catIndex === 0 && (
<TableCell
rowSpan={5}
className={`font-medium border-r border-gray-200 align-top pt-3 sticky left-0 z-10 ${rowBgClass}`}
>
<div className="flex flex-col gap-2">
<span className="text-sm">{vendor.vendorName}</span>
<div className="flex items-center gap-2">
<Switch
checked={vendor.isOverdue}
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
className="data-[state=checked]:bg-red-500"
/>
{vendor.isOverdue && (
<span className="text-xs text-red-500 font-medium"></span>
)}
</div>
</div>
</TableCell>
)}
{/* 구분 - 왼쪽 고정 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
{CATEGORY_LABELS[category]}
</TableCell>
{/* 월별 금액 - 스크롤 영역 */}
{MONTH_KEYS.map((monthKey, monthIndex) => {
const amount = categoryData.amounts[monthKey] || 0;
const isOverdue = isOverdueCell(vendor, monthIndex);
return (
<TableCell
key={monthKey}
className={`text-right text-sm border-r border-gray-200 ${
isOverdue ? 'bg-red-100 text-red-700' : ''
}`}
>
{formatAmount(amount)}
</TableCell>
);
})}
{/* 합계 - 오른쪽 고정 */}
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
{formatAmount(categoryData.amounts.total)}
</TableCell>
</TableRow>
);
})}
{/* 메모 행 - 별도 렌더링 */}
<TableRow key={`${vendor.id}-memo`}>
{/* 구분 - 왼쪽 고정 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
{CATEGORY_LABELS[category]}
</TableCell>
{/* 월별 금액 - 스크롤 영역 */}
{MONTH_KEYS.map((monthKey, monthIndex) => {
const amount = categoryData.amounts[monthKey] || 0;
const isOverdue = isOverdueCell(vendor, monthIndex);
{/* 메모 내용 - 월별 컬럼 + 합계 컬럼 병합 */}
<TableCell colSpan={13} className="p-2">
{hasMemo ? (
<div className="relative">
{/* 메모 내용 */}
<div
className={`text-sm text-gray-700 whitespace-pre-wrap ${
isExpanded
? 'max-h-40 overflow-y-auto'
: 'max-h-10 overflow-hidden'
}`}
>
{vendor.memo}
</div>
return (
<TableCell
key={monthKey}
className={`text-right text-sm border-r border-gray-200 ${
isOverdue ? 'bg-red-100 text-red-700' : ''
}`}
>
{formatAmount(amount)}
</TableCell>
);
})}
{/* 합계 - 오른쪽 고정 */}
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
{formatAmount(categoryData.amounts.total)}
{/* 펼치기/접기 버튼 */}
<button
type="button"
onClick={() => toggleMemoExpand(vendor.id)}
className="mt-1 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</TableCell>
</TableRow>
);
})
))
</Fragment>
);
})
)}
</TableBody>
<TableFooter>

View File

@@ -54,6 +54,7 @@ export interface VendorReceivables {
isOverdue: boolean; // 연체 토글 상태
overdueMonths: number[]; // 연체 월 (1-12)
categories: CategoryData[];
memo?: string; // 거래처별 메모 (단일 텍스트)
}
/**

View File

@@ -12,6 +12,7 @@ import {
Send,
FileText,
Loader2,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -310,6 +311,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -319,7 +321,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
@@ -329,7 +331,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600">
{isNewMode ? '등록' : '저장'}
</Button>
</>

View File

@@ -582,7 +582,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
@@ -641,7 +641,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
</Button>
<Button
onClick={handleConfirmSaveAccountSubject}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
>
</Button>

View File

@@ -3,7 +3,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { FileText, Download, Pencil, Loader2 } from 'lucide-react';
import { FileText, Download, Pencil, Loader2, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
@@ -171,6 +171,7 @@ export function VendorLedgerDetail({
</div>
</div>
<Button variant="outline" size="sm" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
</div>

View File

@@ -300,7 +300,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
</Button>
</div>
@@ -311,7 +311,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
{isSaving ? '처리중...' : isNewMode ? '등록' : '저장'}
</Button>
</div>
@@ -631,7 +631,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
@@ -707,7 +707,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
<AlertDialogCancel disabled={isSaving}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isSaving}
>
{isSaving ? '처리중...' : '확인'}

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Plus, X, Loader2 } from 'lucide-react';
import { Building2, Plus, X, Loader2, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -267,12 +267,13 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete}>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>
@@ -283,7 +284,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isNewMode ? '등록' : '저장'}
</Button>
@@ -548,7 +549,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
className="flex-1 bg-white"
rows={2}
/>
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
<Plus className="h-4 w-4 mr-1" />
</Button>
@@ -625,7 +626,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}

View File

@@ -19,8 +19,6 @@ import type {
ApiResponse,
PaginatedResponse,
VendorCategory,
CLIENT_TYPE_TO_CATEGORY,
CATEGORY_TO_CLIENT_TYPE,
BadDebtStatus,
} from './types';

View File

@@ -4,6 +4,7 @@
// 클라이언트 컴포넌트
export { VendorManagementClient } from './VendorManagementClient';
export { VendorManagementClient as VendorManagement } from './VendorManagementClient';
// 상세/수정 컴포넌트
export { VendorDetail } from './VendorDetail';

View File

@@ -1,6 +1,64 @@
// ===== API 응답 타입 =====
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page?: number;
size?: number;
}
// ===== API 데이터 타입 (백엔드에서 오는 원본 데이터) =====
export interface ClientApiData {
id: number;
client_code: string;
name: string;
client_type: 'SALES' | 'PURCHASE' | 'BOTH';
business_no: string;
contact_person: string;
phone: string;
mobile: string;
fax: string;
email: string;
address: string;
manager_name: string;
manager_tel: string;
system_manager: string;
purchase_payment_day: string;
sales_payment_day: string;
business_type: string;
business_item: string;
bad_debt: boolean;
memo: string;
is_active: boolean;
account_id: string;
outstanding_amount: number;
bad_debt_total: number;
has_bad_debt: boolean;
created_at: string;
updated_at: string;
}
// ===== 거래처 구분 =====
export type VendorCategory = 'sales' | 'purchase' | 'both';
// ===== API client_type ↔ VendorCategory 변환 =====
export const CLIENT_TYPE_TO_CATEGORY: Record<string, VendorCategory> = {
SALES: 'sales',
PURCHASE: 'purchase',
BOTH: 'both',
};
export const CATEGORY_TO_CLIENT_TYPE: Record<VendorCategory, string> = {
sales: 'SALES',
purchase: 'PURCHASE',
both: 'BOTH',
};
export const VENDOR_CATEGORY_LABELS: Record<VendorCategory, string> = {
sales: '매출',
purchase: '매입',
@@ -198,6 +256,7 @@ export interface Vendor {
overdueAmount: number; // 연체금액
overdueDays: number; // 연체일수
unpaidAmount: number; // 미지급
badDebtAmount: number; // 악성채권 금액
badDebtStatus: BadDebtStatus; // 악성채권 상태
overdueToggle: boolean; // 연체 토글
badDebtToggle: boolean; // 악성채권 토글

View File

@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Banknote,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -180,6 +181,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -190,7 +192,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
@@ -202,7 +204,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
</Button>
<Button
onClick={handleSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}

View File

@@ -489,7 +489,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
@@ -578,7 +578,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
</Button>
<Button
onClick={handleConfirmSaveAccountSubject}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
>
</Button>

View File

@@ -9,7 +9,7 @@
'use server';
import { cookies } from 'next/headers';
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
// ============================================
// 타입 정의
@@ -155,12 +155,10 @@ export async function checkIn(
*/
export async function checkOut(
data: CheckOutRequest
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> {
try {
const headers = await getApiHeaders();
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
method: 'POST',
headers,
body: JSON.stringify({
user_id: data.userId,
check_out: data.checkOut,
@@ -195,11 +193,11 @@ export async function checkOut(
success: false,
error: result.message || '퇴근 기록에 실패했습니다.',
};
} catch (error) {
console.error('[checkOut] Error:', error);
} catch (err) {
console.error('[checkOut] Error:', err);
return {
success: false,
error: error instanceof Error ? error.message : '퇴근 기록에 실패했습니다.',
error: err instanceof Error ? err.message : '퇴근 기록에 실패했습니다.',
};
}
}

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Loader2, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -187,6 +187,7 @@ export default function BiddingDetailForm({
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}></Button>

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Loader2 } from 'lucide-react';
import { FileText, Loader2, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
@@ -560,7 +560,7 @@ export default function EstimateDetailForm({
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>
@@ -569,6 +569,7 @@ export default function EstimateDetailForm({
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -578,7 +579,7 @@ export default function EstimateDetailForm({
>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
@@ -718,7 +719,7 @@ export default function EstimateDetailForm({
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}

View File

@@ -137,7 +137,7 @@ export function EstimateDetailTableSection({
type="button"
variant="default"
size="sm"
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
onClick={onApplyAdjustedPrice}
>

View File

@@ -73,7 +73,7 @@ export function PriceAdjustmentSection({
type="button"
variant="default"
size="sm"
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
onClick={onApplyAll}
>

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Plus, X } from 'lucide-react';
import { Package, Plus, X, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -281,6 +281,7 @@ export default function ItemDetailClient({
{mode === 'view' && (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Hammer } from 'lucide-react';
import { Hammer, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -230,6 +230,7 @@ export default function LaborDetailClient({
{mode === 'view' && (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button

View File

@@ -791,7 +791,6 @@ export default function OrderManagementListClient({
// 달력 섹션 추가
beforeTableContent={
<div className="w-full flex-shrink-0 mb-6">
<h3 className="text-sm font-semibold text-muted-foreground mb-3"> </h3>
<ScheduleCalendar
events={calendarEvents}
badges={calendarBadges}
@@ -800,6 +799,7 @@ export default function OrderManagementListClient({
onDateClick={handleCalendarDateClick}
onEventClick={handleCalendarEventClick}
onMonthChange={handleCalendarMonthChange}
titleSlot="발주 스케줄"
filterSlot={calendarFilterSlot}
maxEventsPerDay={3}
weekStartsOn={0}

View File

@@ -0,0 +1,169 @@
'use client';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { OrderDetailFormData, OrderDetailCategory } from '../types';
import { MOCK_PARTNERS, MOCK_CONSTRUCTION_PM } from '../types';
interface ContractInfoCardProps {
formData: OrderDetailFormData;
isViewMode: boolean;
isEditMode: boolean;
onFieldChange: (
field: keyof OrderDetailFormData,
value: string | string[] | OrderDetailCategory[]
) => void;
}
export function ContractInfoCard({
formData,
isViewMode,
isEditMode,
onFieldChange,
}: ContractInfoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.partnerId}
onValueChange={(value) => {
onFieldChange('partnerId', value);
const partner = MOCK_PARTNERS.find((p) => p.value === value);
if (partner) {
onFieldChange('partnerName', partner.label);
}
}}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.siteName}
onChange={(e) => onFieldChange('siteName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractNumber}
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Select
value={formData.constructionPMId}
onValueChange={(value) => {
onFieldChange('constructionPMId', value);
const pm = MOCK_CONSTRUCTION_PM.find((p) => p.value === value);
if (pm) {
onFieldChange('constructionPM', pm.label);
}
}}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_CONSTRUCTION_PM.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 공사담당자 */}
<div className="space-y-2 md:col-span-2 lg:col-span-4">
<Label></Label>
<div className="flex flex-wrap gap-2">
{formData.constructionManagers.map((manager, index) => (
<div key={index} className="flex items-center gap-1">
<Input
value={manager}
onChange={(e) => {
const newManagers = [...formData.constructionManagers];
newManagers[index] = e.target.value;
onFieldChange('constructionManagers', newManagers);
}}
disabled={isViewMode}
className="w-24"
/>
{isEditMode && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
const newManagers = formData.constructionManagers.filter(
(_, i) => i !== index
);
onFieldChange('constructionManagers', newManagers);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
{isEditMode && (
<Button
variant="outline"
size="sm"
onClick={() => {
onFieldChange('constructionManagers', [
...formData.constructionManagers,
'',
]);
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { OrderDetailFormData, OrderStatus, OrderType } from '../types';
import {
ORDER_STATUS_OPTIONS,
ORDER_TYPE_OPTIONS,
MOCK_ORDER_MANAGERS,
MOCK_ORDER_COMPANIES,
} from '../types';
interface OrderInfoCardProps {
formData: OrderDetailFormData;
isViewMode: boolean;
onFieldChange: (field: keyof OrderDetailFormData, value: string) => void;
}
export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 발주번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.orderNumber}
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 발주일 (발주처) */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.orderCompanyId}
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="회사명 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ORDER_COMPANIES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 구분 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.orderType}
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ORDER_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.status}
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ORDER_STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 발주담당자 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.orderManager}
onValueChange={(value) => onFieldChange('orderManager', value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ORDER_MANAGERS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 화물도착지 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.deliveryAddress}
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}
placeholder="주소명"
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface OrderMemoCardProps {
memo: string;
isViewMode: boolean;
onMemoChange: (value: string) => void;
}
export function OrderMemoCard({ memo, isViewMode, onMemoChange }: OrderMemoCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={memo}
onChange={(e) => onMemoChange(e.target.value)}
disabled={isViewMode}
rows={4}
placeholder="비고를 입력하세요"
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScheduleCalendar, ScheduleEvent } from '@/components/common/ScheduleCalendar';
interface OrderScheduleCardProps {
events: ScheduleEvent[];
currentDate: Date;
selectedDate: Date | null;
onDateClick: (date: Date) => void;
onMonthChange: (date: Date) => void;
}
export function OrderScheduleCard({
events,
currentDate,
selectedDate,
onDateClick,
onMonthChange,
}: OrderScheduleCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<ScheduleCalendar
events={events}
badges={[]}
currentDate={currentDate}
selectedDate={selectedDate}
onDateClick={onDateClick}
onEventClick={() => {}}
onMonthChange={onMonthChange}
maxEventsPerDay={3}
weekStartsOn={0}
isLoading={false}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface OrderDialogsProps {
// 저장 다이얼로그
showSaveDialog: boolean;
onSaveDialogChange: (open: boolean) => void;
onConfirmSave: () => void;
// 삭제 다이얼로그
showDeleteDialog: boolean;
onDeleteDialogChange: (open: boolean) => void;
onConfirmDelete: () => void;
// 카테고리 삭제 다이얼로그
showCategoryDeleteDialog: string | null;
onCategoryDeleteDialogChange: (categoryId: string | null) => void;
onConfirmDeleteCategory: () => void;
// 공통
isLoading: boolean;
}
export function OrderDialogs({
showSaveDialog,
onSaveDialogChange,
onConfirmSave,
showDeleteDialog,
onDeleteDialogChange,
onConfirmDelete,
showCategoryDeleteDialog,
onCategoryDeleteDialogChange,
onConfirmDeleteCategory,
isLoading,
}: OrderDialogsProps) {
return (
<>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={onSaveDialogChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> ?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={onConfirmSave} disabled={isLoading}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={onDeleteDialogChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 카테고리 삭제 확인 다이얼로그 */}
<AlertDialog
open={!!showCategoryDeleteDialog}
onOpenChange={() => onCategoryDeleteDialogChange(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDeleteCategory}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,526 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type {
OrderDetail,
OrderDetailFormData,
OrderDetailItem,
OrderDetailCategory,
OrderStatus,
OrderType,
} from '../types';
import {
MOCK_PARTNERS,
MOCK_CONSTRUCTION_PM,
MOCK_CATEGORIES,
getEmptyOrderDetailItem,
getEmptyOrderDetailCategory,
getEmptyOrderDetailFormData,
orderDetailToFormData,
} from '../types';
import { updateOrder, deleteOrder, duplicateOrder } from '../actions';
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar';
interface UseOrderDetailFormProps {
mode: 'view' | 'edit';
orderId: string;
initialData?: OrderDetail;
}
interface UseOrderDetailFormReturn {
// Mode flags
isViewMode: boolean;
isEditMode: boolean;
// Form data
formData: OrderDetailFormData;
// Loading state
isLoading: boolean;
// Dialog states
showDeleteDialog: boolean;
setShowDeleteDialog: (show: boolean) => void;
showSaveDialog: boolean;
setShowSaveDialog: (show: boolean) => void;
showCategoryDeleteDialog: string | null;
setShowCategoryDeleteDialog: (categoryId: string | null) => void;
// Modal states
showDocumentModal: boolean;
setShowDocumentModal: (show: boolean) => void;
// Selection states
selectedItems: Map<string, Set<string>>;
addCounts: Map<string, number>;
setAddCounts: React.Dispatch<React.SetStateAction<Map<string, number>>>;
categoryFilters: Map<string, string>;
// Calendar states
calendarDate: Date;
selectedCalendarDate: Date | null;
calendarEvents: ScheduleEvent[];
// Navigation handlers
handleBack: () => void;
handleEdit: () => void;
handleCancel: () => void;
// Form handlers
handleFieldChange: (
field: keyof OrderDetailFormData,
value: string | number | boolean | string[] | OrderDetailCategory[]
) => void;
// CRUD handlers
handleSave: () => void;
handleConfirmSave: () => Promise<void>;
handleDelete: () => void;
handleConfirmDelete: () => Promise<void>;
handleDuplicate: () => Promise<void>;
handleViewDocument: () => void;
// Category handlers
handleAddCategory: () => void;
handleDeleteCategory: (categoryId: string) => void;
handleConfirmDeleteCategory: () => void;
handleCategoryChange: (
categoryId: string,
field: keyof OrderDetailCategory,
value: string
) => void;
// Item handlers
handleAddItems: (categoryId: string, count: number) => void;
handleDeleteSelectedItems: (categoryId: string) => void;
handleDeleteAllItems: (categoryId: string) => void;
handleItemChange: (
categoryId: string,
itemId: string,
field: keyof OrderDetailItem,
value: string | number
) => void;
// Selection handlers
handleToggleSelection: (categoryId: string, itemId: string) => void;
handleToggleSelectAll: (categoryId: string, items: OrderDetailItem[]) => void;
// Calendar handlers
handleCalendarDateClick: (date: Date) => void;
handleCalendarMonthChange: (date: Date) => void;
}
export function useOrderDetailForm({
mode,
orderId,
initialData,
}: UseOrderDetailFormProps): UseOrderDetailFormReturn {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// Form data
const [formData, setFormData] = useState<OrderDetailFormData>(
initialData ? orderDetailToFormData(initialData) : getEmptyOrderDetailFormData()
);
// Loading state
const [isLoading, setIsLoading] = useState(false);
// Dialog states
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showCategoryDeleteDialog, setShowCategoryDeleteDialog] = useState<string | null>(null);
// Modal states
const [showDocumentModal, setShowDocumentModal] = useState(false);
// Category table selection states
const [selectedItems, setSelectedItems] = useState<Map<string, Set<string>>>(new Map());
// Category add counts
const [addCounts, setAddCounts] = useState<Map<string, number>>(new Map());
// Category filters
const [categoryFilters, setCategoryFilters] = useState<Map<string, string>>(new Map());
// Calendar states
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
// ============================================
// Navigation handlers
// ============================================
const handleBack = useCallback(() => {
router.push('/ko/juil/order/order-management');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/juil/order/order-management/${orderId}/edit`);
}, [router, orderId]);
const handleCancel = useCallback(() => {
router.push(`/ko/juil/order/order-management/${orderId}`);
}, [router, orderId]);
// ============================================
// Form field handlers
// ============================================
const handleFieldChange = useCallback(
(
field: keyof OrderDetailFormData,
value: string | number | boolean | string[] | OrderDetailCategory[]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// ============================================
// Save handlers
// ============================================
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateOrder(orderId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/juil/order/order-management/${orderId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, orderId, formData]);
// ============================================
// Delete handlers
// ============================================
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
const result = await deleteOrder(orderId);
if (result.success) {
toast.success('발주가 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/juil/order/order-management');
router.refresh();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, orderId]);
// ============================================
// Duplicate handler
// ============================================
const handleDuplicate = useCallback(async () => {
setIsLoading(true);
try {
const result = await duplicateOrder(orderId);
if (result.success && result.newId) {
toast.success('발주가 복제되었습니다.');
router.push(`/ko/juil/order/order-management/${result.newId}/edit`);
} else {
toast.error(result.error || '복제에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '복제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, orderId]);
// ============================================
// Document modal handler
// ============================================
const handleViewDocument = useCallback(() => {
setShowDocumentModal(true);
}, []);
// ============================================
// Category handlers
// ============================================
const handleAddCategory = useCallback(() => {
const newCategory = getEmptyOrderDetailCategory();
setFormData((prev) => ({
...prev,
orderCategories: [...prev.orderCategories, newCategory],
}));
}, []);
const handleDeleteCategory = useCallback((categoryId: string) => {
setShowCategoryDeleteDialog(categoryId);
}, []);
const handleConfirmDeleteCategory = useCallback(() => {
if (showCategoryDeleteDialog) {
setFormData((prev) => ({
...prev,
orderCategories: prev.orderCategories.filter(
(cat) => cat.id !== showCategoryDeleteDialog
),
}));
setSelectedItems((prev) => {
const newMap = new Map(prev);
newMap.delete(showCategoryDeleteDialog);
return newMap;
});
setShowCategoryDeleteDialog(null);
}
}, [showCategoryDeleteDialog]);
const handleCategoryChange = useCallback(
(categoryId: string, field: keyof OrderDetailCategory, value: string) => {
setFormData((prev) => ({
...prev,
orderCategories: prev.orderCategories.map((cat) =>
cat.id === categoryId ? { ...cat, [field]: value } : cat
),
}));
},
[]
);
// ============================================
// Item handlers
// ============================================
const handleAddItems = useCallback((categoryId: string, count: number) => {
if (count <= 0) {
toast.warning('추가할 개수를 입력해주세요.');
return;
}
const newItems: OrderDetailItem[] = Array.from({ length: count }, () => ({
...getEmptyOrderDetailItem(),
id: String(Date.now() + Math.random()),
}));
setFormData((prev) => ({
...prev,
orderCategories: prev.orderCategories.map((cat) =>
cat.id === categoryId ? { ...cat, items: [...cat.items, ...newItems] } : cat
),
}));
setAddCounts((prev) => {
const newMap = new Map(prev);
newMap.set(categoryId, 1);
return newMap;
});
}, []);
const handleDeleteSelectedItems = useCallback(
(categoryId: string) => {
const selected = selectedItems.get(categoryId);
if (!selected || selected.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setFormData((prev) => ({
...prev,
orderCategories: prev.orderCategories.map((cat) =>
cat.id === categoryId
? { ...cat, items: cat.items.filter((item) => !selected.has(item.id)) }
: cat
),
}));
setSelectedItems((prev) => {
const newMap = new Map(prev);
newMap.set(categoryId, new Set());
return newMap;
});
},
[selectedItems]
);
const handleDeleteAllItems = useCallback((categoryId: string) => {
setFormData((prev) => ({
...prev,
orderCategories: prev.orderCategories.map((cat) =>
cat.id === categoryId ? { ...cat, items: [] } : cat
),
}));
setSelectedItems((prev) => {
const newMap = new Map(prev);
newMap.set(categoryId, new Set());
return newMap;
});
}, []);
const handleItemChange = useCallback(
(
categoryId: string,
itemId: string,
field: keyof OrderDetailItem,
value: string | number
) => {
setFormData((prev) => ({
...prev,
orderCategories: prev.orderCategories.map((cat) =>
cat.id === categoryId
? {
...cat,
items: cat.items.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}
: cat
),
}));
},
[]
);
// ============================================
// Selection handlers
// ============================================
const handleToggleSelection = useCallback((categoryId: string, itemId: string) => {
setSelectedItems((prev) => {
const newMap = new Map(prev);
const categorySet = newMap.get(categoryId) || new Set();
const newSet = new Set(categorySet);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
newMap.set(categoryId, newSet);
return newMap;
});
}, []);
const handleToggleSelectAll = useCallback(
(categoryId: string, items: OrderDetailItem[]) => {
setSelectedItems((prev) => {
const newMap = new Map(prev);
const categorySet = newMap.get(categoryId) || new Set();
if (categorySet.size === items.length) {
newMap.set(categoryId, new Set());
} else {
newMap.set(categoryId, new Set(items.map((item) => item.id)));
}
return newMap;
});
},
[]
);
// ============================================
// Calendar handlers
// ============================================
const calendarEvents: ScheduleEvent[] = useMemo(() => {
if (!initialData?.scheduleEvents) return [];
return initialData.scheduleEvents.map((event) => ({
id: event.id,
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
color: event.color,
status: 'order_complete' as const,
data: event,
}));
}, [initialData?.scheduleEvents]);
const handleCalendarDateClick = useCallback((date: Date) => {
setSelectedCalendarDate((prev) => {
if (prev && prev.getTime() === date.getTime()) {
return null;
}
return date;
});
}, []);
const handleCalendarMonthChange = useCallback((date: Date) => {
setCalendarDate(date);
}, []);
return {
// Mode flags
isViewMode,
isEditMode,
// Form data
formData,
// Loading state
isLoading,
// Dialog states
showDeleteDialog,
setShowDeleteDialog,
showSaveDialog,
setShowSaveDialog,
showCategoryDeleteDialog,
setShowCategoryDeleteDialog,
// Modal states
showDocumentModal,
setShowDocumentModal,
// Selection states
selectedItems,
addCounts,
setAddCounts,
categoryFilters,
// Calendar states
calendarDate,
selectedCalendarDate,
calendarEvents,
// Navigation handlers
handleBack,
handleEdit,
handleCancel,
// Form handlers
handleFieldChange,
// CRUD handlers
handleSave,
handleConfirmSave,
handleDelete,
handleConfirmDelete,
handleDuplicate,
handleViewDocument,
// Category handlers
handleAddCategory,
handleDeleteCategory,
handleConfirmDeleteCategory,
handleCategoryChange,
// Item handlers
handleAddItems,
handleDeleteSelectedItems,
handleDeleteAllItems,
handleItemChange,
// Selection handlers
handleToggleSelection,
handleToggleSelectAll,
// Calendar handlers
handleCalendarDateClick,
handleCalendarMonthChange,
};
}

View File

@@ -0,0 +1,451 @@
'use client';
import { Plus, Trash2, Image as ImageIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import type { OrderDetailCategory, OrderDetailItem, OrderStatus } from '../types';
import {
ORDER_STATUS_OPTIONS,
ORDER_STATUS_LABELS,
ORDER_STATUS_STYLES,
MOCK_WORK_TEAM_LEADERS,
MOCK_CATEGORIES,
MOCK_ITEM_NAMES,
} from '../types';
interface OrderDetailItemTableProps {
category: OrderDetailCategory;
isEditMode: boolean;
isViewMode: boolean;
selectedItems: Set<string>;
addCount: number;
onAddCountChange: (count: number) => void;
onAddItems: (count: number) => void;
onDeleteSelectedItems: () => void;
onDeleteAllItems: () => void;
onCategoryChange: (field: keyof OrderDetailCategory, value: string) => void;
onItemChange: (itemId: string, field: keyof OrderDetailItem, value: string | number) => void;
onToggleSelection: (itemId: string) => void;
onToggleSelectAll: () => void;
}
export function OrderDetailItemTable({
category,
isEditMode,
isViewMode,
selectedItems,
addCount,
onAddCountChange,
onAddItems,
onDeleteSelectedItems,
onDeleteAllItems,
onCategoryChange,
onItemChange,
onToggleSelection,
onToggleSelectAll,
}: OrderDetailItemTableProps) {
const renderStatusBadge = (status: OrderStatus) => {
return (
<Badge className={ORDER_STATUS_STYLES[status]}>{ORDER_STATUS_LABELS[status]}</Badge>
);
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
{/* 왼쪽: 발주 상세, N건 선택, 삭제 */}
<div className="flex items-center gap-4">
<CardTitle className="text-lg"> </CardTitle>
{isEditMode && (
<>
<span className="text-sm text-muted-foreground">
{selectedItems.size}
</span>
<Button
variant="default"
size="sm"
onClick={onDeleteSelectedItems}
disabled={selectedItems.size === 0}
>
</Button>
</>
)}
</div>
{/* 오른쪽 끝: 숫자, 추가, 카테고리, 🗑️ */}
{isEditMode && (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={addCount}
onChange={(e) => onAddCountChange(parseInt(e.target.value) || 1)}
className="w-16 h-8"
/>
<Button variant="default" size="sm" onClick={() => onAddItems(addCount)}>
</Button>
<Select
value={category.categoryId || 'none'}
onValueChange={(value) => {
onCategoryChange('categoryId', value);
const cat = MOCK_CATEGORIES.find((c) => c.value === value);
if (cat) {
onCategoryChange('categoryName', cat.label);
}
}}
>
<SelectTrigger className="w-32 h-8">
<SelectValue placeholder="카테고리명" />
</SelectTrigger>
<SelectContent>
{MOCK_CATEGORIES.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
onClick={onDeleteAllItems}
className="text-destructive h-8 w-8"
title="전체 삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{isEditMode && (
<TableHead className="w-[40px]">
<Checkbox
checked={
category.items.length > 0 &&
selectedItems.size === category.items.length
}
onCheckedChange={onToggleSelectAll}
/>
</TableHead>
)}
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{category.items.length === 0 ? (
<TableRow>
<TableCell
colSpan={isEditMode ? 18 : 17}
className="text-center text-muted-foreground py-8"
>
.
</TableCell>
</TableRow>
) : (
category.items.map((item, index) => (
<TableRow key={item.id}>
{isEditMode && (
<TableCell>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={() => onToggleSelection(item.id)}
/>
</TableCell>
)}
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={item.workTeamLeader || 'none'}
onValueChange={(value) =>
onItemChange(item.id, 'workTeamLeader', value)
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_WORK_TEAM_LEADERS.map((opt) => (
<SelectItem key={opt.value} value={opt.label}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
item.workTeamLeader || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="date"
value={item.constructionStartDate}
onChange={(e) =>
onItemChange(item.id, 'constructionStartDate', e.target.value)
}
className="h-8"
/>
) : (
item.constructionStartDate || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="date"
value={item.constructionEndDate}
onChange={(e) =>
onItemChange(item.id, 'constructionEndDate', e.target.value)
}
className="h-8"
/>
) : (
item.constructionEndDate || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={item.name}
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
className="h-8"
/>
) : (
item.name || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={item.product}
onChange={(e) => onItemChange(item.id, 'product', e.target.value)}
className="h-8"
/>
) : (
item.product || '-'
)}
</TableCell>
<TableCell className="text-right">
{isEditMode ? (
<Input
type="number"
value={item.width}
onChange={(e) =>
onItemChange(item.id, 'width', parseInt(e.target.value) || 0)
}
className="h-8 text-right"
/>
) : (
item.width || '-'
)}
</TableCell>
<TableCell className="text-right">
{isEditMode ? (
<Input
type="number"
value={item.height}
onChange={(e) =>
onItemChange(item.id, 'height', parseInt(e.target.value) || 0)
}
className="h-8 text-right"
/>
) : (
item.height || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={item.itemName || 'none'}
onValueChange={(value) => onItemChange(item.id, 'itemName', value)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_ITEM_NAMES.map((opt) => (
<SelectItem key={opt.value} value={opt.label}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
item.itemName || '-'
)}
</TableCell>
<TableCell className="text-right">
{isEditMode ? (
<Input
type="number"
value={item.quantity}
onChange={(e) =>
onItemChange(item.id, 'quantity', parseInt(e.target.value) || 0)
}
className="h-8 text-right"
/>
) : (
item.quantity
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={item.unit}
onChange={(e) => onItemChange(item.id, 'unit', e.target.value)}
className="h-8 w-14"
/>
) : (
item.unit || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={item.remark}
onChange={(e) => onItemChange(item.id, 'remark', e.target.value)}
className="h-8"
/>
) : (
item.remark || '-'
)}
</TableCell>
<TableCell className="text-center">
{item.imageUrl ? (
<Button variant="ghost" size="icon" className="h-8 w-8">
<ImageIcon className="h-4 w-4" />
</Button>
) : (
'-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="date"
value={item.orderDate}
onChange={(e) =>
onItemChange(item.id, 'orderDate', e.target.value)
}
className="h-8"
/>
) : (
item.orderDate || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="date"
value={item.plannedDeliveryDate}
onChange={(e) =>
onItemChange(item.id, 'plannedDeliveryDate', e.target.value)
}
className="h-8"
/>
) : (
item.plannedDeliveryDate || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
type="date"
value={item.actualDeliveryDate}
onChange={(e) =>
onItemChange(item.id, 'actualDeliveryDate', e.target.value)
}
className="h-8"
/>
) : (
item.actualDeliveryDate || '-'
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={item.status}
onValueChange={(value) =>
onItemChange(item.id, 'status', value as OrderStatus)
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ORDER_STATUS_OPTIONS.filter((o) => o.value !== 'all').map(
(opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
) : (
renderStatusBadge(item.status)
)}
</TableCell>
</TableRow>
))
)}
{/* 합계 행 */}
{category.items.length > 0 && (
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={isEditMode ? 10 : 9} className="text-right">
</TableCell>
<TableCell className="text-right">
{category.items.reduce((sum, item) => sum + item.quantity, 0)}
</TableCell>
<TableCell colSpan={isEditMode ? 7 : 7}></TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon, Download } from 'lucide-react';
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon, Download, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -360,9 +360,10 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>
@@ -372,6 +373,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -381,7 +383,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
@@ -394,7 +396,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
@@ -723,7 +725,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
<Button
type="button"
onClick={handleAddMemo}
className="bg-orange-500 hover:bg-orange-600 self-end"
className="bg-blue-500 hover:bg-blue-600 self-end"
>
<Plus className="h-4 w-4 mr-1" />
@@ -875,7 +877,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign } from 'lucide-react';
import { DollarSign, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -283,6 +283,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
{isViewMode && (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)}>

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download } from 'lucide-react';
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -326,9 +326,10 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>
@@ -338,6 +339,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
@@ -347,7 +349,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
@@ -360,7 +362,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
@@ -725,7 +727,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Upload, Mic, X, FileText, Download } from 'lucide-react';
import { Building2, Upload, Mic, X, FileText, Download, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -228,6 +228,7 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
!isEditMode ? (
<>
<Button variant="outline" onClick={() => router.push('/ko/juil/order/site-management')}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEditClick}></Button>

View File

@@ -2,7 +2,7 @@
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { ClipboardCheck, Upload, X, FileText, Download } from 'lucide-react';
import { ClipboardCheck, Upload, X, FileText, Download, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -279,6 +279,7 @@ export default function StructureReviewDetailForm({
isViewMode ? (
<>
<Button variant="outline" onClick={handleGoToList}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleDeleteClick}>

View File

@@ -18,6 +18,7 @@ export function CalendarHeader({
onPrevMonth,
onNextMonth,
onViewChange,
titleSlot,
filterSlot,
}: CalendarHeaderProps) {
const views: { value: CalendarView; label: string }[] = [
@@ -27,29 +28,34 @@ export function CalendarHeader({
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
{/* 좌측: 년월 네비게이션 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* 좌측: 타이틀 + 년월 네비게이션 */}
<div className="flex items-center gap-4">
{titleSlot && (
<span className="text-base font-semibold text-foreground">{titleSlot}</span>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-lg font-bold min-w-[120px] text-center">
{formatYearMonth(currentDate)}
</span>
<span className="text-lg font-bold min-w-[120px] text-center">
{formatYearMonth(currentDate)}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* 우측: 뷰 전환 + 필터 */}

View File

@@ -38,6 +38,7 @@ export function ScheduleCalendar({
onEventClick,
onMonthChange,
onViewChange,
titleSlot,
filterSlot,
maxEventsPerDay = 3,
weekStartsOn = 0,
@@ -115,6 +116,7 @@ export function ScheduleCalendar({
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
onViewChange={handleViewChange}
titleSlot={titleSlot}
filterSlot={filterSlot}
/>

View File

@@ -61,6 +61,8 @@ export interface ScheduleCalendarProps {
onMonthChange?: (date: Date) => void;
/** 뷰 모드 변경 핸들러 */
onViewChange?: (view: CalendarView) => void;
/** 타이틀 영역 (년월 네비게이션 왼쪽) */
titleSlot?: React.ReactNode;
/** 필터 영역 (slot) */
filterSlot?: React.ReactNode;
/** 최대 표시 이벤트 수 (초과 시 +N 표시) */
@@ -82,6 +84,8 @@ export interface CalendarHeaderProps {
onPrevMonth: () => void;
onNextMonth: () => void;
onViewChange: (view: CalendarView) => void;
/** 타이틀 영역 (년월 네비게이션 왼쪽) */
titleSlot?: React.ReactNode;
filterSlot?: React.ReactNode;
}

View File

@@ -125,6 +125,15 @@ function transformFrontendToApi(data: AttendanceFormData): Record<string, unknow
};
}
/**
* 분을 휴게시간 문자열로 변환 (예: 90 -> "1:30")
*/
function formatMinutesToBreakTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, '0')}`;
}
/**
* 분을 시간 문자열로 변환 (예: 210 -> "3시간 30분")
*/

View File

@@ -34,7 +34,7 @@ import {
EMPLOYEE_STATUS_LABELS,
DEFAULT_FIELD_SETTINGS,
} from './types';
import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions';
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
import { getProfileImageUrl } from './utils';
interface EmployeeFormProps {
@@ -464,8 +464,10 @@ export function EmployeeForm({
if (file) {
// 미리보기 즉시 표시
handleChange('profileImage', URL.createObjectURL(file));
// 서버에 업로드
const result = await uploadProfileImage(file);
// 서버에 업로드 (FormData로 감싸서 전송)
const formData = new FormData();
formData.append('file', file);
const result = await uploadProfileImage(formData);
if (result.success && result.data?.url) {
handleChange('profileImage', result.data.url);
}

View File

@@ -16,7 +16,7 @@
'use server';
import { cookies } from 'next/headers';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
import type { Employee, EmployeeFormData, EmployeeStats } from './types';
import { transformApiToFrontend, transformFrontendToApi, type EmployeeApiData } from './utils';
@@ -365,7 +365,7 @@ export interface PositionItem {
*/
export async function getPositions(type?: 'rank' | 'title'): Promise<PositionItem[]> {
try {
const headers = await getApiHeaders();
const headers = await getServerApiHeaders();
const searchParams = new URLSearchParams();
if (type) {
searchParams.set('type', type);
@@ -414,7 +414,7 @@ export interface DepartmentItem {
*/
export async function getDepartments(): Promise<DepartmentItem[]> {
try {
const headers = await getApiHeaders();
const headers = await getServerApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments`,
@@ -449,7 +449,7 @@ export async function getDepartments(): Promise<DepartmentItem[]> {
// 파일 업로드
// ============================================
export async function uploadProfileImage(formData: FormData): Promise<{
export async function uploadProfileImage(inputFormData: FormData): Promise<{
success: boolean;
data?: { url: string; path: string };
error?: string;
@@ -464,9 +464,8 @@ export async function uploadProfileImage(formData: FormData): Promise<{
return { success: false, __authError: true };
}
const formData = new FormData();
formData.append('file', file);
formData.append('directory', 'employees/profiles');
// 디렉토리 정보 추가
inputFormData.append('directory', 'employees/profiles');
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
@@ -476,7 +475,7 @@ export async function uploadProfileImage(formData: FormData): Promise<{
'Authorization': `Bearer ${token}`,
'X-API-KEY': process.env.API_KEY || '',
},
body: formData,
body: inputFormData,
}
);

View File

@@ -40,10 +40,10 @@ function generateLotNo(): string {
// 기본 검사 항목
const defaultInspectionItems: InspectionCheckItem[] = [
{ id: '1', name: '겉모양', specification: '외관 이상 없음', method: '육안', judgment: null, remark: '' },
{ id: '2', name: '두께', specification: 't 1.0', method: '계측', judgment: null, remark: '' },
{ id: '3', name: '폭', specification: 'W 1,000mm', method: '계측', judgment: null, remark: '' },
{ id: '4', name: '길이', specification: 'L 2,000mm', method: '계측', judgment: null, remark: '' },
{ id: '1', name: '겉모양', specification: '외관 이상 없음', method: '육안', judgment: '', remark: '' },
{ id: '2', name: '두께', specification: 't 1.0', method: '계측', judgment: '', remark: '' },
{ id: '3', name: '폭', specification: 'W 1,000mm', method: '계측', judgment: '', remark: '' },
{ id: '4', name: '길이', specification: 'L 2,000mm', method: '계측', judgment: '', remark: '' },
];
interface Props {

View File

@@ -7,7 +7,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Package, AlertCircle, Loader2 } from 'lucide-react';
import { Package, AlertCircle, Loader2, List } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -109,6 +109,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
<h1 className="text-xl font-semibold"> </h1>
</div>
<Button variant="outline" onClick={handleGoBack}>
<List className="h-4 w-4 mr-2" />
</Button>
</div>
@@ -140,6 +141,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
</Badge>
</div>
<Button variant="outline" onClick={handleGoBack}>
<List className="h-4 w-4 mr-2" />
</Button>
</div>

View File

@@ -22,7 +22,7 @@ interface StatCardsProps {
export function StatCards({ stats }: StatCardsProps) {
return (
<div className="flex flex-col sm:flex-row gap-3 md:gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
{stats.map((stat, index) => {
const Icon = stat.icon;
const isClickable = !!stat.onClick;

View File

@@ -40,7 +40,7 @@ import {
PRIORITY_STYLES,
DELIVERY_METHOD_LABELS,
} from './types';
import type { ShipmentItem, ShipmentStatus, ShipmentStats } from './types';
import type { ShipmentItem, ShipmentStatus, ShipmentStats, ShipmentStatusStats } from './types';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -55,7 +55,7 @@ export function ShipmentList() {
// API 데이터 상태
const [items, setItems] = useState<ShipmentItem[]>([]);
const [shipmentStats, setShipmentStats] = useState<ShipmentStats | null>(null);
const [statusStats, setStatusStats] = useState<Record<string, { label: string; count: number }>>({});
const [statusStats, setStatusStats] = useState<ShipmentStatusStats>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalItems, setTotalItems] = useState(0);

View File

@@ -23,6 +23,7 @@ import type {
ShipmentDetail,
ShipmentProduct,
ShipmentStats,
ShipmentStatusStats,
ShipmentStatus,
ShipmentPriority,
DeliveryMethod,
@@ -184,15 +185,38 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
}
// ===== API → Frontend 변환 (통계용) =====
function transformApiToStats(data: ShipmentApiStatsResponse): ShipmentStats {
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number }): ShipmentStats {
return {
todayShipmentCount: data.today_shipment_count,
scheduledCount: data.scheduled_count,
shippingCount: data.shipping_count,
urgentCount: data.urgent_count,
totalCount: data.total_count || 0,
};
}
// ===== API → Frontend 변환 (상태별 통계용) =====
const STATUS_TAB_LABELS: Record<string, string> = {
all: '전체',
scheduled: '출고예정',
ready: '출하대기',
shipping: '배송중',
completed: '배송완료',
};
function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): ShipmentStatusStats {
const result: ShipmentStatusStats = {};
for (const [key, count] of Object.entries(data)) {
if (key !== 'all') { // all은 탭에서 제외 (전체 탭은 별도 처리)
result[key] = {
label: STATUS_TAB_LABELS[key] || key,
count: count as number,
};
}
}
return result;
}
// ===== Frontend → API 변환 (등록용) =====
function transformCreateFormToApi(
data: ShipmentCreateFormData
@@ -400,7 +424,7 @@ export async function getShipmentStats(): Promise<{
// ===== 상태별 통계 조회 (탭용) =====
export async function getShipmentStatsByStatus(): Promise<{
success: boolean;
data?: ShipmentApiStatsByStatusResponse;
data?: ShipmentStatusStats;
error?: string;
__authError?: boolean;
}> {
@@ -428,7 +452,7 @@ export async function getShipmentStatsByStatus(): Promise<{
return { success: false, error: result.message || '상태별 통계 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
return { success: true, data: transformApiToStatsByStatus(result.data) };
} catch (error) {
console.error('[ShipmentActions] getShipmentStatsByStatus error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };

View File

@@ -18,6 +18,7 @@ export const mockStats: ShipmentStats = {
scheduledCount: 5,
shippingCount: 1,
urgentCount: 4,
totalCount: 20,
};
// 필터 탭

View File

@@ -156,6 +156,15 @@ export interface ShipmentStats {
scheduledCount: number; // 출고 대기
shippingCount: number; // 배송중
urgentCount: number; // 긴급 출하
totalCount: number; // 전체 건수
}
// 상태별 통계 (탭용)
export interface ShipmentStatusStats {
[key: string]: {
label: string;
count: number;
};
}
// 필터 탭

View File

@@ -75,10 +75,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
useEffect(() => {
const loadDepartments = async () => {
setIsDepartmentsLoading(true);
const result = await getDepartmentOptions();
if (result.success && result.data) {
setDepartmentOptions(result.data);
}
const departments = await getDepartmentOptions();
setDepartmentOptions(departments);
setIsDepartmentsLoading(false);
};
loadDepartments();
@@ -256,8 +254,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
</SelectTrigger>
<SelectContent>
{departmentOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.name}>
{opt.name}
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>

View File

@@ -83,14 +83,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
// 품목 목록 로드 (debounced)
const loadItems = useCallback(async (q?: string, itemType?: string) => {
setIsItemsLoading(true);
const result = await getItemList({
const items = await getItemList({
q: q || undefined,
itemType: itemType === 'all' ? undefined : itemType,
size: 100,
});
if (result.success && result.data) {
setItemList(result.data);
}
setItemList(items);
setIsItemsLoading(false);
}, []);

View File

@@ -449,3 +449,110 @@ export async function getProcessStats(): Promise<{
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ============================================================================
// 부서 옵션 타입 및 함수
// ============================================================================
export interface DepartmentOption {
value: string;
label: string;
}
/**
* 부서 목록 조회
*/
export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error || !response?.ok) {
// 기본 부서 옵션 반환
return [
{ value: '생산부', label: '생산부' },
{ value: '품질관리부', label: '품질관리부' },
{ value: '물류부', label: '물류부' },
{ value: '영업부', label: '영업부' },
];
}
const result = await response.json();
if (result.success && result.data) {
return result.data.map((dept: { id: number; name: string }) => ({
value: dept.name,
label: dept.name,
}));
}
return [];
} catch (error) {
console.error('[getDepartmentOptions] Error:', error);
return [];
}
}
// ============================================================================
// 품목 옵션 타입 및 함수
// ============================================================================
export interface ItemOption {
value: string;
label: string;
code: string;
id: string;
fullName: string;
type: string;
}
interface GetItemListParams {
q?: string;
itemType?: string;
size?: number;
}
/**
* 품목 목록 조회 (분류 규칙용)
*/
export async function getItemList(params?: GetItemListParams): Promise<ItemOption[]> {
try {
const searchParams = new URLSearchParams();
searchParams.set('size', String(params?.size || 1000));
if (params?.q) searchParams.set('q', params.q);
if (params?.itemType) searchParams.set('item_type', params.itemType);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?${searchParams.toString()}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error || !response?.ok) {
return [];
}
const result = await response.json();
if (result.success && result.data?.data) {
return result.data.data.map((item: { id: number; item_name: string; item_code?: string; item_type?: string }) => ({
value: String(item.id),
label: item.item_name,
code: item.item_code || '',
id: String(item.id),
fullName: item.item_name,
type: item.item_type || '',
}));
}
return [];
} catch (error) {
console.error('[getItemList] Error:', error);
return [];
}
}

View File

@@ -6,7 +6,7 @@
export { QuoteManagementClient } from './QuoteManagementClient';
// 기존 컴포넌트
export { default as QuoteDocument } from './QuoteDocument';
export { QuoteDocument } from './QuoteDocument';
export { QuoteRegistration, INITIAL_QUOTE_FORM } from './QuoteRegistration';
export { QuoteCalculationReport } from './QuoteCalculationReport';
export { PurchaseOrderDocument } from './PurchaseOrderDocument';

View File

@@ -59,7 +59,7 @@ export function AccountManagement() {
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
@@ -69,7 +69,7 @@ export function AccountManagement() {
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
// API 데이터
@@ -98,7 +98,7 @@ export function AccountManagement() {
}, [loadData]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: number) => {
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
@@ -132,7 +132,7 @@ export function AccountManagement() {
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(filteredData.map(item => item.id)));
setSelectedItems(new Set(filteredData.map(item => String(item.id))));
}
}, [selectedItems.size, filteredData]);
@@ -145,7 +145,7 @@ export function AccountManagement() {
router.push(`/ko/settings/accounts/${item.id}?mode=edit`);
}, [router]);
const handleDeleteClick = useCallback((id: number) => {
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
@@ -155,10 +155,10 @@ export function AccountManagement() {
setIsDeleting(true);
try {
const result = await deleteBankAccount(deleteTargetId);
const result = await deleteBankAccount(Number(deleteTargetId));
if (result.success) {
toast.success('계좌가 삭제되었습니다.');
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setData(prev => prev.filter(item => String(item.id) !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
@@ -183,7 +183,7 @@ export function AccountManagement() {
}, [selectedItems.size]);
const handleConfirmBulkDelete = useCallback(async () => {
const ids = Array.from(selectedItems);
const ids = Array.from(selectedItems).map(id => Number(id));
setIsDeleting(true);
try {
const result = await deleteBankAccounts(ids);
@@ -192,7 +192,7 @@ export function AccountManagement() {
if (result.error) {
toast.warning(result.error);
}
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setData(prev => prev.filter(item => !selectedItems.has(String(item.id))));
setSelectedItems(new Set());
} else {
toast.error(result.error || '계좌 삭제에 실패했습니다.');
@@ -222,7 +222,8 @@ export function AccountManagement() {
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: Account, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
const itemIdStr = String(item.id);
const isSelected = selectedItems.has(itemIdStr);
return (
<TableRow
@@ -231,7 +232,7 @@ export function AccountManagement() {
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(itemIdStr)} />
</TableCell>
<TableCell className="text-muted-foreground text-center">{globalIndex}</TableCell>
<TableCell>{BANK_LABELS[item.bankCode] || item.bankCode}</TableCell>
@@ -257,7 +258,7 @@ export function AccountManagement() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(item.id)}
onClick={() => handleDeleteClick(itemIdStr)}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
@@ -279,7 +280,7 @@ export function AccountManagement() {
) => {
return (
<ListMobileCard
id={item.id}
id={String(item.id)}
title={item.accountName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
@@ -321,7 +322,7 @@ export function AccountManagement() {
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => { e.stopPropagation(); handleDeleteClick(item.id); }}
onClick={(e) => { e.stopPropagation(); handleDeleteClick(String(item.id)); }}
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -358,7 +359,7 @@ export function AccountManagement() {
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: Account) => item.id}
getItemId={(item: Account) => String(item.id)}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{

View File

@@ -1,557 +0,0 @@
'use client';
/**
* 알림설정 페이지 클라이언트 컴포넌트
*
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
* 클라이언트에서 프록시를 통해 API 호출 (토큰 갱신 자동 처리)
*/
import { useState, useCallback, useEffect } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Bell, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { toast } from 'sonner';
import type { NotificationSettings, NotificationItem } from './types';
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
// ===== 알림 항목 컴포넌트 =====
interface NotificationItemRowProps {
label: string;
item: NotificationItem;
onChange: (item: NotificationItem) => void;
disabled?: boolean;
}
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
return (
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
<div className="flex items-center gap-4 flex-1">
<span className="text-sm min-w-[160px]">{label}</span>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={item.email}
onCheckedChange={(checked) =>
onChange({ ...item, email: checked === true })
}
disabled={disabled || !item.enabled}
/>
<span className="text-sm text-muted-foreground"></span>
</label>
</div>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
}
disabled={disabled}
/>
</div>
);
}
// ===== 알림 섹션 컴포넌트 =====
interface NotificationSectionProps {
title: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
children: React.ReactNode;
}
function NotificationSection({ title, enabled, onEnabledChange, children }: NotificationSectionProps) {
return (
<Card>
<div className="flex items-center justify-between px-6 pt-6 pb-3">
<CardTitle className="text-base font-medium">{title}</CardTitle>
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
/>
</div>
<CardContent className="pt-0">
<div className="pl-4">
{children}
</div>
</CardContent>
</Card>
);
}
// ===== API 응답과 기본값 병합 =====
function mergeWithDefaults(apiData: Partial<NotificationSettings>): NotificationSettings {
return {
notice: {
enabled: apiData.notice?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.notice.enabled,
notice: apiData.notice?.notice ?? DEFAULT_NOTIFICATION_SETTINGS.notice.notice,
event: apiData.notice?.event ?? DEFAULT_NOTIFICATION_SETTINGS.notice.event,
},
schedule: {
enabled: apiData.schedule?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.enabled,
vatReport: apiData.schedule?.vatReport ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.vatReport,
incomeTaxReport: apiData.schedule?.incomeTaxReport ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.incomeTaxReport,
},
vendor: {
enabled: apiData.vendor?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.enabled,
newVendor: apiData.vendor?.newVendor ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.newVendor,
creditRating: apiData.vendor?.creditRating ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.creditRating,
},
attendance: {
enabled: apiData.attendance?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.enabled,
annualLeave: apiData.attendance?.annualLeave ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.annualLeave,
clockIn: apiData.attendance?.clockIn ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.clockIn,
late: apiData.attendance?.late ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.late,
absent: apiData.attendance?.absent ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.absent,
},
order: {
enabled: apiData.order?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.order.enabled,
salesOrder: apiData.order?.salesOrder ?? DEFAULT_NOTIFICATION_SETTINGS.order.salesOrder,
purchaseOrder: apiData.order?.purchaseOrder ?? DEFAULT_NOTIFICATION_SETTINGS.order.purchaseOrder,
},
approval: {
enabled: apiData.approval?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.approval.enabled,
approvalRequest: apiData.approval?.approvalRequest ?? DEFAULT_NOTIFICATION_SETTINGS.approval.approvalRequest,
draftApproved: apiData.approval?.draftApproved ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftApproved,
draftRejected: apiData.approval?.draftRejected ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftRejected,
draftCompleted: apiData.approval?.draftCompleted ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftCompleted,
},
production: {
enabled: apiData.production?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.production.enabled,
safetyStock: apiData.production?.safetyStock ?? DEFAULT_NOTIFICATION_SETTINGS.production.safetyStock,
productionComplete: apiData.production?.productionComplete ?? DEFAULT_NOTIFICATION_SETTINGS.production.productionComplete,
},
};
}
export function NotificationSettingsClient() {
const [settings, setSettings] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// ===== 데이터 로드 (프록시 패턴) =====
useEffect(() => {
async function loadSettings() {
try {
const response = await fetch('/api/proxy/settings/notifications', {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
const result = await response.json();
if (result.success && result.data) {
setSettings(mergeWithDefaults(result.data));
}
} catch (error) {
console.error('[NotificationSettings] Load error:', error);
toast.error('알림 설정을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
}
loadSettings();
}, []);
// ===== 공지 알림 핸들러 =====
const handleNoticeEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
notice: {
...prev.notice,
enabled,
...(enabled ? {} : {
notice: { ...prev.notice.notice, enabled: false, email: false },
event: { ...prev.notice.event, enabled: false, email: false },
}),
},
}));
}, []);
const handleNoticeItemChange = useCallback((key: 'notice' | 'event', item: NotificationItem) => {
setSettings(prev => ({
...prev,
notice: { ...prev.notice, [key]: item },
}));
}, []);
// ===== 일정 알림 핸들러 =====
const handleScheduleEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
schedule: {
...prev.schedule,
enabled,
...(enabled ? {} : {
vatReport: { ...prev.schedule.vatReport, enabled: false, email: false },
incomeTaxReport: { ...prev.schedule.incomeTaxReport, enabled: false, email: false },
}),
},
}));
}, []);
const handleScheduleItemChange = useCallback((key: 'vatReport' | 'incomeTaxReport', item: NotificationItem) => {
setSettings(prev => ({
...prev,
schedule: { ...prev.schedule, [key]: item },
}));
}, []);
// ===== 거래처 알림 핸들러 =====
const handleVendorEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
vendor: {
...prev.vendor,
enabled,
...(enabled ? {} : {
newVendor: { ...prev.vendor.newVendor, enabled: false, email: false },
creditRating: { ...prev.vendor.creditRating, enabled: false, email: false },
}),
},
}));
}, []);
const handleVendorItemChange = useCallback((key: 'newVendor' | 'creditRating', item: NotificationItem) => {
setSettings(prev => ({
...prev,
vendor: { ...prev.vendor, [key]: item },
}));
}, []);
// ===== 근태 알림 핸들러 =====
const handleAttendanceEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
attendance: {
...prev.attendance,
enabled,
...(enabled ? {} : {
annualLeave: { ...prev.attendance.annualLeave, enabled: false, email: false },
clockIn: { ...prev.attendance.clockIn, enabled: false, email: false },
late: { ...prev.attendance.late, enabled: false, email: false },
absent: { ...prev.attendance.absent, enabled: false, email: false },
}),
},
}));
}, []);
const handleAttendanceItemChange = useCallback((
key: 'annualLeave' | 'clockIn' | 'late' | 'absent',
item: NotificationItem
) => {
setSettings(prev => ({
...prev,
attendance: { ...prev.attendance, [key]: item },
}));
}, []);
// ===== 수주/발주 알림 핸들러 =====
const handleOrderEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
order: {
...prev.order,
enabled,
...(enabled ? {} : {
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
}),
},
}));
}, []);
const handleOrderItemChange = useCallback((key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
setSettings(prev => ({
...prev,
order: { ...prev.order, [key]: item },
}));
}, []);
// ===== 전자결재 알림 핸들러 =====
const handleApprovalEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
approval: {
...prev.approval,
enabled,
...(enabled ? {} : {
approvalRequest: { ...prev.approval.approvalRequest, enabled: false, email: false },
draftApproved: { ...prev.approval.draftApproved, enabled: false, email: false },
draftRejected: { ...prev.approval.draftRejected, enabled: false, email: false },
draftCompleted: { ...prev.approval.draftCompleted, enabled: false, email: false },
}),
},
}));
}, []);
const handleApprovalItemChange = useCallback((
key: 'approvalRequest' | 'draftApproved' | 'draftRejected' | 'draftCompleted',
item: NotificationItem
) => {
setSettings(prev => ({
...prev,
approval: { ...prev.approval, [key]: item },
}));
}, []);
// ===== 생산 알림 핸들러 =====
const handleProductionEnabledChange = useCallback((enabled: boolean) => {
setSettings(prev => ({
...prev,
production: {
...prev.production,
enabled,
...(enabled ? {} : {
safetyStock: { ...prev.production.safetyStock, enabled: false, email: false },
productionComplete: { ...prev.production.productionComplete, enabled: false, email: false },
}),
},
}));
}, []);
const handleProductionItemChange = useCallback((
key: 'safetyStock' | 'productionComplete',
item: NotificationItem
) => {
setSettings(prev => ({
...prev,
production: { ...prev.production, [key]: item },
}));
}, []);
// ===== 저장 (프록시 패턴) =====
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
const response = await fetch('/api/proxy/settings/notifications', {
method: 'PUT',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
const result = await response.json();
if (response.ok && result.success) {
toast.success('알림 설정이 저장되었습니다.');
if (result.data) {
setSettings(mergeWithDefaults(result.data));
}
} else {
toast.error(result.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('[NotificationSettings] Save error:', error);
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [settings]);
// ===== 로딩 UI =====
if (isLoading) {
return (
<PageLayout>
<PageHeader
title="알림설정"
description="알림 설정을 관리합니다."
icon={Bell}
/>
<ContentLoadingSpinner text="알림 설정을 불러오는 중..." />
</PageLayout>
);
}
return (
<PageLayout>
<PageHeader
title="알림설정"
description="알림 설정을 관리합니다."
icon={Bell}
/>
<div className="space-y-4">
{/* 공지 알림 */}
<NotificationSection
title="공지 알림"
enabled={settings.notice.enabled}
onEnabledChange={handleNoticeEnabledChange}
>
<NotificationItemRow
label="공지사항 알림"
item={settings.notice.notice}
onChange={(item) => handleNoticeItemChange('notice', item)}
disabled={!settings.notice.enabled}
/>
<NotificationItemRow
label="이벤트 알림"
item={settings.notice.event}
onChange={(item) => handleNoticeItemChange('event', item)}
disabled={!settings.notice.enabled}
/>
</NotificationSection>
{/* 일정 알림 */}
<NotificationSection
title="일정 알림"
enabled={settings.schedule.enabled}
onEnabledChange={handleScheduleEnabledChange}
>
<NotificationItemRow
label="부가세 신고 알림"
item={settings.schedule.vatReport}
onChange={(item) => handleScheduleItemChange('vatReport', item)}
disabled={!settings.schedule.enabled}
/>
<NotificationItemRow
label="종합소득세 신고 알림"
item={settings.schedule.incomeTaxReport}
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
disabled={!settings.schedule.enabled}
/>
</NotificationSection>
{/* 거래처 알림 */}
<NotificationSection
title="거래처 알림"
enabled={settings.vendor.enabled}
onEnabledChange={handleVendorEnabledChange}
>
<NotificationItemRow
label="신규 업체 등록 알림"
item={settings.vendor.newVendor}
onChange={(item) => handleVendorItemChange('newVendor', item)}
disabled={!settings.vendor.enabled}
/>
<NotificationItemRow
label="신용등급 등록 알림"
item={settings.vendor.creditRating}
onChange={(item) => handleVendorItemChange('creditRating', item)}
disabled={!settings.vendor.enabled}
/>
</NotificationSection>
{/* 근태 알림 */}
<NotificationSection
title="근태 알림"
enabled={settings.attendance.enabled}
onEnabledChange={handleAttendanceEnabledChange}
>
<NotificationItemRow
label="연차 알림"
item={settings.attendance.annualLeave}
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="출근 알림"
item={settings.attendance.clockIn}
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="지각 알림"
item={settings.attendance.late}
onChange={(item) => handleAttendanceItemChange('late', item)}
disabled={!settings.attendance.enabled}
/>
<NotificationItemRow
label="결근 알림"
item={settings.attendance.absent}
onChange={(item) => handleAttendanceItemChange('absent', item)}
disabled={!settings.attendance.enabled}
/>
</NotificationSection>
{/* 수주/발주 알림 */}
<NotificationSection
title="수주/발주 알림"
enabled={settings.order.enabled}
onEnabledChange={handleOrderEnabledChange}
>
<NotificationItemRow
label="수주 등록 알림"
item={settings.order.salesOrder}
onChange={(item) => handleOrderItemChange('salesOrder', item)}
disabled={!settings.order.enabled}
/>
<NotificationItemRow
label="발주 알림"
item={settings.order.purchaseOrder}
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
disabled={!settings.order.enabled}
/>
</NotificationSection>
{/* 전자결재 알림 */}
<NotificationSection
title="전자결재 알림"
enabled={settings.approval.enabled}
onEnabledChange={handleApprovalEnabledChange}
>
<NotificationItemRow
label="결재요청 알림"
item={settings.approval.approvalRequest}
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 승인 알림"
item={settings.approval.draftApproved}
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 반려 알림"
item={settings.approval.draftRejected}
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
disabled={!settings.approval.enabled}
/>
<NotificationItemRow
label="기안 > 완료 알림"
item={settings.approval.draftCompleted}
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
disabled={!settings.approval.enabled}
/>
</NotificationSection>
{/* 생산 알림 */}
<NotificationSection
title="생산 알림"
enabled={settings.production.enabled}
onEnabledChange={handleProductionEnabledChange}
>
<NotificationItemRow
label="안전재고 알림"
item={settings.production.safetyStock}
onChange={(item) => handleProductionItemChange('safetyStock', item)}
disabled={!settings.production.enabled}
/>
<NotificationItemRow
label="생산완료 알림"
item={settings.production.productionComplete}
onChange={(item) => handleProductionItemChange('productionComplete', item)}
disabled={!settings.production.enabled}
/>
</NotificationSection>
{/* 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button onClick={handleSave} size="lg" disabled={isSaving}>
<Save className="h-4 w-4 mr-2" />
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -109,11 +109,53 @@ export async function saveNotificationSettings(
}
}
// ===== API → Frontend 변환 =====
// ===== API → Frontend 변환 (기본값과 병합) =====
function transformApiToFrontend(apiData: Record<string, unknown>): NotificationSettings {
// API 응답이 이미 프론트엔드 형식과 동일하다고 가정
// 필요시 snake_case → camelCase 변환
return apiData as NotificationSettings;
// API 응답에 soundType이 없을 수 있으므로 기본값과 병합
const data = apiData as Partial<NotificationSettings>;
return {
notice: {
enabled: data.notice?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.notice.enabled,
notice: { ...DEFAULT_NOTIFICATION_SETTINGS.notice.notice, ...data.notice?.notice },
event: { ...DEFAULT_NOTIFICATION_SETTINGS.notice.event, ...data.notice?.event },
},
schedule: {
enabled: data.schedule?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.enabled,
vatReport: { ...DEFAULT_NOTIFICATION_SETTINGS.schedule.vatReport, ...data.schedule?.vatReport },
incomeTaxReport: { ...DEFAULT_NOTIFICATION_SETTINGS.schedule.incomeTaxReport, ...data.schedule?.incomeTaxReport },
},
vendor: {
enabled: data.vendor?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.enabled,
newVendor: { ...DEFAULT_NOTIFICATION_SETTINGS.vendor.newVendor, ...data.vendor?.newVendor },
creditRating: { ...DEFAULT_NOTIFICATION_SETTINGS.vendor.creditRating, ...data.vendor?.creditRating },
},
attendance: {
enabled: data.attendance?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.enabled,
annualLeave: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.annualLeave, ...data.attendance?.annualLeave },
clockIn: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.clockIn, ...data.attendance?.clockIn },
late: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.late, ...data.attendance?.late },
absent: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.absent, ...data.attendance?.absent },
},
order: {
enabled: data.order?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.order.enabled,
salesOrder: { ...DEFAULT_NOTIFICATION_SETTINGS.order.salesOrder, ...data.order?.salesOrder },
purchaseOrder: { ...DEFAULT_NOTIFICATION_SETTINGS.order.purchaseOrder, ...data.order?.purchaseOrder },
approvalRequest: { ...DEFAULT_NOTIFICATION_SETTINGS.order.approvalRequest, ...data.order?.approvalRequest },
},
approval: {
enabled: data.approval?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.approval.enabled,
approvalRequest: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.approvalRequest, ...data.approval?.approvalRequest },
draftApproved: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftApproved, ...data.approval?.draftApproved },
draftRejected: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftRejected, ...data.approval?.draftRejected },
draftCompleted: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftCompleted, ...data.approval?.draftCompleted },
},
production: {
enabled: data.production?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.production.enabled,
safetyStock: { ...DEFAULT_NOTIFICATION_SETTINGS.production.safetyStock, ...data.production?.safetyStock },
productionComplete: { ...DEFAULT_NOTIFICATION_SETTINGS.production.productionComplete, ...data.production?.productionComplete },
},
};
}
// ===== Frontend → API 변환 =====

View File

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

View File

@@ -3,21 +3,39 @@
/**
* 알림설정 페이지
*
* 각 알림 유형별로 ON/OFF 토글 이메일 알림 체크박스를 제공합니다.
* 각 알림 유형별로 ON/OFF 토글, 알림 소리 선택, 이메일 알림 체크박스를 제공합니다.
*/
import { useState } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Bell, Save } from 'lucide-react';
import { Bell, Save, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import type { NotificationSettings, NotificationItem } from './types';
import type { NotificationSettings, NotificationItem, SoundType } from './types';
import { SOUND_OPTIONS } from './types';
import { saveNotificationSettings } from './actions';
// 미리듣기 함수
function playPreviewSound(soundType: SoundType) {
if (soundType === 'mute') {
toast.info('무음으로 설정되어 있습니다.');
return;
}
const soundName = soundType === 'default' ? '기본 알림음' : 'SAM 보이스';
toast.info(`${soundName} 미리듣기`);
}
// 알림 항목 컴포넌트
interface NotificationItemRowProps {
label: string;
@@ -27,28 +45,74 @@ interface NotificationItemRowProps {
}
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
const isDisabled = disabled || !item.enabled;
return (
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
<div className="flex items-center gap-4 flex-1">
<span className="text-sm min-w-[160px]">{label}</span>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={item.email}
<div className="flex items-center justify-between py-4 border-b last:border-b-0">
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{label}</span>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
onChange({ ...item, email: checked === true })
onChange({
...item,
enabled: checked,
email: checked ? item.email : false
})
}
disabled={disabled || !item.enabled}
disabled={disabled}
/>
<span className="text-sm text-muted-foreground"></span>
</label>
</div>
{/* 알림 소리 선택 */}
<div className="flex items-center gap-2 pl-2">
<span className="text-sm text-muted-foreground min-w-[80px]"> </span>
<Select
value={item.soundType}
onValueChange={(value: SoundType) =>
onChange({ ...item, soundType: value })
}
disabled={isDisabled}
>
<SelectTrigger className="w-[140px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => playPreviewSound(item.soundType)}
disabled={isDisabled}
>
<Play className="h-3 w-3" />
</Button>
</div>
{/* 추가 알림 선택 */}
<div className="flex items-center gap-2 pl-2">
<span className="text-sm text-muted-foreground min-w-[80px]"> </span>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={item.email}
onCheckedChange={(checked) =>
onChange({ ...item, email: checked === true })
}
disabled={isDisabled}
/>
<span className="text-sm text-muted-foreground"></span>
</label>
</div>
</div>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
}
disabled={disabled}
/>
</div>
);
}
@@ -195,12 +259,13 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
...(enabled ? {} : {
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
approvalRequest: { ...prev.order.approvalRequest, enabled: false, email: false },
}),
},
}));
};
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder' | 'approvalRequest', item: NotificationItem) => {
setSettings(prev => ({
...prev,
order: { ...prev.order, [key]: item },
@@ -388,6 +453,12 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
disabled={!settings.order.enabled}
/>
<NotificationItemRow
label="결재요청 알림"
item={settings.order.approvalRequest}
onChange={(item) => handleOrderItemChange('approvalRequest', item)}
disabled={!settings.order.enabled}
/>
</NotificationSection>
{/* 전자결재 알림 */}

View File

@@ -1,11 +1,50 @@
/**
* 알림 설정 타입 정의
*
* ========================================
* [2026-01-05] 백엔드 API 수정 필요 사항
* ========================================
*
* 1. NotificationItem에 soundType 필드 추가
* - 기존: { enabled: boolean, email: boolean }
* - 변경: { enabled: boolean, email: boolean, soundType: 'default' | 'sam_voice' | 'mute' }
*
* 2. OrderNotificationSettings에 approvalRequest 항목 추가
* - 기존: { salesOrder, purchaseOrder }
* - 변경: { salesOrder, purchaseOrder, approvalRequest }
*
* 3. API 응답 예시:
* {
* "notice": {
* "enabled": true,
* "notice": { "enabled": true, "email": false, "soundType": "default" },
* "event": { "enabled": true, "email": true, "soundType": "sam_voice" }
* },
* "order": {
* "enabled": true,
* "salesOrder": { ... },
* "purchaseOrder": { ... },
* "approvalRequest": { "enabled": false, "email": false, "soundType": "default" } // 새로 추가
* }
* }
* ========================================
*/
// 알림 소리 타입 (NEW: 2026-01-05)
export type SoundType = 'default' | 'sam_voice' | 'mute';
// 알림 소리 옵션
export const SOUND_OPTIONS: { value: SoundType; label: string }[] = [
{ value: 'default', label: '기본 알림음' },
{ value: 'sam_voice', label: 'SAM 보이스' },
{ value: 'mute', label: '무음' },
];
// 개별 알림 항목 설정
export interface NotificationItem {
enabled: boolean;
email: boolean;
soundType: SoundType;
}
// 공지 알림 설정
@@ -43,6 +82,7 @@ export interface OrderNotificationSettings {
enabled: boolean;
salesOrder: NotificationItem; // 수주 등록 알림
purchaseOrder: NotificationItem; // 발주 알림
approvalRequest: NotificationItem; // 결재요청 알림
}
// 전자결재 알림 설정
@@ -76,46 +116,48 @@ export interface NotificationSettings {
export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = {
enabled: false,
email: false,
soundType: 'default',
};
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
notice: {
enabled: false,
notice: { enabled: false, email: false },
event: { enabled: true, email: false },
notice: { enabled: false, email: false, soundType: 'default' },
event: { enabled: true, email: false, soundType: 'default' },
},
schedule: {
enabled: false,
vatReport: { enabled: false, email: false },
incomeTaxReport: { enabled: true, email: false },
vatReport: { enabled: false, email: false, soundType: 'mute' },
incomeTaxReport: { enabled: true, email: false, soundType: 'sam_voice' },
},
vendor: {
enabled: false,
newVendor: { enabled: false, email: false },
creditRating: { enabled: true, email: false },
newVendor: { enabled: false, email: false, soundType: 'default' },
creditRating: { enabled: true, email: false, soundType: 'sam_voice' },
},
attendance: {
enabled: false,
annualLeave: { enabled: false, email: false },
clockIn: { enabled: true, email: false },
late: { enabled: false, email: false },
absent: { enabled: true, email: false },
annualLeave: { enabled: false, email: false, soundType: 'default' },
clockIn: { enabled: true, email: false, soundType: 'sam_voice' },
late: { enabled: false, email: false, soundType: 'default' },
absent: { enabled: true, email: false, soundType: 'sam_voice' },
},
order: {
enabled: false,
salesOrder: { enabled: false, email: false },
purchaseOrder: { enabled: true, email: false },
salesOrder: { enabled: false, email: false, soundType: 'default' },
purchaseOrder: { enabled: true, email: false, soundType: 'sam_voice' },
approvalRequest: { enabled: false, email: false, soundType: 'default' },
},
approval: {
enabled: false,
approvalRequest: { enabled: false, email: false },
draftApproved: { enabled: true, email: false },
draftRejected: { enabled: false, email: false },
draftCompleted: { enabled: true, email: false },
approvalRequest: { enabled: false, email: false, soundType: 'default' },
draftApproved: { enabled: true, email: false, soundType: 'sam_voice' },
draftRejected: { enabled: false, email: false, soundType: 'default' },
draftCompleted: { enabled: true, email: false, soundType: 'sam_voice' },
},
production: {
enabled: false,
safetyStock: { enabled: false, email: false },
productionComplete: { enabled: true, email: false },
safetyStock: { enabled: false, email: false, soundType: 'default' },
productionComplete: { enabled: true, email: false, soundType: 'sam_voice' },
},
};

View File

@@ -1,3 +1,4 @@
export { PaymentHistoryClient } from './PaymentHistoryClient';
export { PaymentHistoryClient as PaymentHistoryManagement } from './PaymentHistoryClient';
export * from './types';
export * from './actions';

View File

@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-gray-400 text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:border-gray-500 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

File diff suppressed because one or more lines are too long