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:
@@ -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 🆕
|
||||
```
|
||||
@@ -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 |
|
||||
```
|
||||
@@ -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 모드 모두 테스트 필수
|
||||
- **점진적 진행**: 한 번에 모든 분리보다 단계별 진행 권장
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? '등록' : '저장'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? '등록' : '저장'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,6 +39,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
|
||||
category: cat.category,
|
||||
amounts: cat.amounts,
|
||||
})),
|
||||
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface VendorReceivables {
|
||||
isOverdue: boolean; // 연체 토글 상태
|
||||
overdueMonths: number[]; // 연체 월 (1-12)
|
||||
categories: CategoryData[];
|
||||
memo?: string; // 거래처별 메모 (단일 텍스트)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? '처리중...' : '확인'}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -19,8 +19,6 @@ import type {
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
VendorCategory,
|
||||
CLIENT_TYPE_TO_CATEGORY,
|
||||
CATEGORY_TO_CLIENT_TYPE,
|
||||
BadDebtStatus,
|
||||
} from './types';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
// 클라이언트 컴포넌트
|
||||
export { VendorManagementClient } from './VendorManagementClient';
|
||||
export { VendorManagementClient as VendorManagement } from './VendorManagementClient';
|
||||
|
||||
// 상세/수정 컴포넌트
|
||||
export { VendorDetail } from './VendorDetail';
|
||||
|
||||
@@ -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; // 악성채권 토글
|
||||
|
||||
@@ -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 ? '등록' : '저장'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 : '퇴근 기록에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
조정 단가 적용
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
전체 적용
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 우측: 뷰 전환 + 필터 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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분")
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '서버 오류가 발생했습니다.' };
|
||||
|
||||
@@ -18,6 +18,7 @@ export const mockStats: ShipmentStats = {
|
||||
scheduledCount: 5,
|
||||
shippingCount: 1,
|
||||
urgentCount: 4,
|
||||
totalCount: 20,
|
||||
};
|
||||
|
||||
// 필터 탭
|
||||
|
||||
@@ -156,6 +156,15 @@ export interface ShipmentStats {
|
||||
scheduledCount: number; // 출고 대기
|
||||
shippingCount: number; // 배송중
|
||||
urgentCount: number; // 긴급 출하
|
||||
totalCount: number; // 전체 건수
|
||||
}
|
||||
|
||||
// 상태별 통계 (탭용)
|
||||
export interface ShipmentStatusStats {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 변환 =====
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { NotificationSettingsClient } from './NotificationSettingsClient';
|
||||
export * from './types';
|
||||
@@ -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>
|
||||
|
||||
{/* 전자결재 알림 */}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { PaymentHistoryClient } from './PaymentHistoryClient';
|
||||
export { PaymentHistoryClient as PaymentHistoryManagement } from './PaymentHistoryClient';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user