diff --git a/claudedocs/guides/[DESIGN-2026-01-02] document-modal-common-component.md b/claudedocs/guides/[DESIGN-2026-01-02] document-modal-common-component.md index 565469ff..1a3681bc 100644 --- a/claudedocs/guides/[DESIGN-2026-01-02] document-modal-common-component.md +++ b/claudedocs/guides/[DESIGN-2026-01-02] document-modal-common-component.md @@ -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 -// 결재라인 컴포넌트 - +interface ApprovalLineProps { + // 방법 1: 단순 열 지정 + columns?: 2 | 3 | 4; + approvers?: Array<{ + role: string; // '작성' | '검토' | '승인' + name: string; + department?: string; + date?: string; + status?: 'pending' | 'approved' | 'rejected'; + }>; -// 문서 타이틀 컴포넌트 - + // 방법 2: 기존 ApprovalLineBox 호환 + drafter?: Approver; + dynamicApprovers?: Approver[]; -// 회사 로고 컴포넌트 - + // 옵션 + showDateRow?: boolean; // 날짜행 표시 여부 + showStatusIcon?: boolean; // 상태 아이콘 표시 여부 +} ``` -### 3. 헤더 레이아웃 프리셋 +### 3. DocumentHeaderLayout (프리셋) ```tsx -// 3열 레이아웃: 로고 | 타이틀 | 결재 +type HeaderVariant = + | 'three-column' // [로고] [제목] [결재] + | 'two-column' // [제목+번호] [결재] + | 'single-center' // [제목 중앙] + | 'approval-first' // [결재] + [정보 테이블] + - + - -// 2열 레이아웃: 타이틀 | 결재 - - - - - -// 1열 레이아웃: 타이틀만 (중앙) - - - ``` --- -## 컴포넌트 구조 제안 +## 컴포넌트 구조 제안 (수정) ``` 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 용지 설정 자동 적용 -- 기존 스타일시트 자동 로드 \ No newline at end of file +- 기존 스타일시트 자동 로드 + +--- + +## 관련 파일 경로 + +``` +문서 모달 관련 파일들: + +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 🆕 +``` \ No newline at end of file diff --git a/claudedocs/guides/[IMPL-2026-01-05] stat-cards-grid-layout.md b/claudedocs/guides/[IMPL-2026-01-05] stat-cards-grid-layout.md new file mode 100644 index 00000000..fe266070 --- /dev/null +++ b/claudedocs/guides/[IMPL-2026-01-05] stat-cards-grid-layout.md @@ -0,0 +1,60 @@ +# StatCards 컴포넌트 레이아웃 변경 + +## 변경일 +2026-01-05 + +## 변경 파일 +`/src/components/organisms/StatCards.tsx` + +## 변경 내용 + +### Before (flex 기반) +```tsx +
+``` +- 모바일: 세로 1열 +- SM 이상: 가로 한 줄로 모든 카드 표시 (`flex-1`) + +### After (grid 기반) +```tsx +
+``` +- 모바일: 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 | +``` \ No newline at end of file diff --git a/claudedocs/juil/[PLAN-2026-01-05] order-detail-form-separation.md b/claudedocs/juil/[PLAN-2026-01-05] order-detail-form-separation.md new file mode 100644 index 00000000..8c636d20 --- /dev/null +++ b/claudedocs/juil/[PLAN-2026-01-05] order-detail-form-separation.md @@ -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; + 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 모드 모두 테스트 필수 +- **점진적 진행**: 한 번에 모든 분리보다 단계별 진행 권장 diff --git a/src/app/[locale]/(protected)/hr/attendance/page.tsx b/src/app/[locale]/(protected)/hr/attendance/page.tsx index d2039dce..0c54b435 100644 --- a/src/app/[locale]/(protected)/hr/attendance/page.tsx +++ b/src/app/[locale]/(protected)/hr/attendance/page.tsx @@ -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) { diff --git a/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx index 949a1456..997f7a4f 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/[id]/edit/page.tsx @@ -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(); diff --git a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx index 962c5496..fd0646d5 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx @@ -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(); diff --git a/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx b/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx index f5964262..2f23ea43 100644 --- a/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/[id]/page.tsx @@ -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); diff --git a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx index 625ad803..bcd2104e 100644 --- a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx +++ b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx @@ -334,7 +334,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp -
@@ -345,7 +345,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp -
@@ -917,7 +917,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp className="flex-1 bg-white" rows={2} /> - @@ -988,7 +988,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp 취소 저장 diff --git a/src/components/accounting/BillManagement/BillDetail.tsx b/src/components/accounting/BillManagement/BillDetail.tsx index b8e6a399..e8f2de11 100644 --- a/src/components/accounting/BillManagement/BillDetail.tsx +++ b/src/components/accounting/BillManagement/BillDetail.tsx @@ -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 ? ( <> - @@ -295,7 +297,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) { diff --git a/src/components/accounting/BillManagement/index.tsx b/src/components/accounting/BillManagement/index.tsx index b3d462fd..2bb34800 100644 --- a/src/components/accounting/BillManagement/index.tsx +++ b/src/components/accounting/BillManagement/index.tsx @@ -472,7 +472,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem {/* 저장 버튼 */} - diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 81fcee20..19a871b5 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -364,7 +364,7 @@ export function CardTransactionInquiry({ ))} - @@ -487,7 +487,7 @@ export function CardTransactionInquiry({ - @@ -202,7 +204,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) { @@ -584,7 +584,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan diff --git a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx index 55e6a3f8..75fdf040 100644 --- a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx +++ b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx @@ -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 ? ( <> - @@ -312,7 +313,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { - diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx index f34ae85b..b282f409 100644 --- a/src/components/accounting/PurchaseManagement/index.tsx +++ b/src/components/accounting/PurchaseManagement/index.tsx @@ -539,7 +539,7 @@ export function PurchaseManagement() { ))} - @@ -622,7 +622,7 @@ export function PurchaseManagement() { diff --git a/src/components/accounting/ReceivablesStatus/actions.ts b/src/components/accounting/ReceivablesStatus/actions.ts index 1a89c4d6..da3378dd 100644 --- a/src/components/accounting/ReceivablesStatus/actions.ts +++ b/src/components/accounting/ReceivablesStatus/actions.ts @@ -39,6 +39,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables { category: cat.category, amounts: cat.amounts, })), + memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '', }; } diff --git a/src/components/accounting/ReceivablesStatus/index.tsx b/src/components/accounting/ReceivablesStatus/index.tsx index 3629b9b2..ebf7a423 100644 --- a/src/components/accounting/ReceivablesStatus/index.tsx +++ b/src/components/accounting/ReceivablesStatus/index.tsx @@ -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>(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 ( @@ -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 ? ( @@ -345,78 +359,131 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial ) : ( - 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 ( + + {/* 카테고리 행들 (매출, 입금, 어음, 미수금) */} + {categoryOrder.map((category, catIndex) => { + const categoryData = vendor.categories.find(c => c.category === category); + if (!categoryData) return null; - return ( - - {/* 거래처명 + 연체 토글 - 왼쪽 고정 */} - {catIndex === 0 && ( - -
- {vendor.vendorName} -
- handleOverdueToggle(vendor.id, checked)} - className="data-[state=checked]:bg-red-500" - /> - {vendor.isOverdue && ( - 연체 - )} -
-
-
- )} + {/* 거래처명 + 연체 토글 - 왼쪽 고정 */} + {catIndex === 0 && ( + +
+ {vendor.vendorName} +
+ handleOverdueToggle(vendor.id, checked)} + className="data-[state=checked]:bg-red-500" + /> + {vendor.isOverdue && ( + 연체 + )} +
+
+
+ )} + {/* 구분 - 왼쪽 고정 */} + + {CATEGORY_LABELS[category]} + + + {/* 월별 금액 - 스크롤 영역 */} + {MONTH_KEYS.map((monthKey, monthIndex) => { + const amount = categoryData.amounts[monthKey] || 0; + const isOverdue = isOverdueCell(vendor, monthIndex); + + return ( + + {formatAmount(amount)} + + ); + })} + + {/* 합계 - 오른쪽 고정 */} + + {formatAmount(categoryData.amounts.total)} + +
+ ); + })} + + {/* 메모 행 - 별도 렌더링 */} + {/* 구분 - 왼쪽 고정 */} - {CATEGORY_LABELS[category]} + 메모 - {/* 월별 금액 - 스크롤 영역 */} - {MONTH_KEYS.map((monthKey, monthIndex) => { - const amount = categoryData.amounts[monthKey] || 0; - const isOverdue = isOverdueCell(vendor, monthIndex); + {/* 메모 내용 - 월별 컬럼 + 합계 컬럼 병합 */} + + {hasMemo ? ( +
+ {/* 메모 내용 */} +
+ {vendor.memo} +
- return ( - - {formatAmount(amount)} - - ); - })} - - {/* 합계 - 오른쪽 고정 */} - - {formatAmount(categoryData.amounts.total)} + {/* 펼치기/접기 버튼 */} + +
+ ) : ( + - + )}
- ); - }) - )) +
+ ); + }) )} diff --git a/src/components/accounting/ReceivablesStatus/types.ts b/src/components/accounting/ReceivablesStatus/types.ts index 24f41c06..d7a391d3 100644 --- a/src/components/accounting/ReceivablesStatus/types.ts +++ b/src/components/accounting/ReceivablesStatus/types.ts @@ -54,6 +54,7 @@ export interface VendorReceivables { isOverdue: boolean; // 연체 토글 상태 overdueMonths: number[]; // 연체 월 (1-12) categories: CategoryData[]; + memo?: string; // 거래처별 메모 (단일 텍스트) } /** diff --git a/src/components/accounting/SalesManagement/SalesDetail.tsx b/src/components/accounting/SalesManagement/SalesDetail.tsx index 9ebb65ab..2682290c 100644 --- a/src/components/accounting/SalesManagement/SalesDetail.tsx +++ b/src/components/accounting/SalesManagement/SalesDetail.tsx @@ -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 ? ( <> - @@ -329,7 +331,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) { - diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx index ddb6974b..2d3188d3 100644 --- a/src/components/accounting/SalesManagement/index.tsx +++ b/src/components/accounting/SalesManagement/index.tsx @@ -582,7 +582,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem ))} - @@ -641,7 +641,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem diff --git a/src/components/accounting/VendorLedger/VendorLedgerDetail.tsx b/src/components/accounting/VendorLedger/VendorLedgerDetail.tsx index 8d901405..49be9045 100644 --- a/src/components/accounting/VendorLedger/VendorLedgerDetail.tsx +++ b/src/components/accounting/VendorLedger/VendorLedgerDetail.tsx @@ -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({ diff --git a/src/components/accounting/VendorManagement/VendorDetail.tsx b/src/components/accounting/VendorManagement/VendorDetail.tsx index 93e5f5e6..a9c95f68 100644 --- a/src/components/accounting/VendorManagement/VendorDetail.tsx +++ b/src/components/accounting/VendorManagement/VendorDetail.tsx @@ -300,7 +300,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) { > 삭제 - @@ -311,7 +311,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) { - @@ -631,7 +631,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) { className="flex-1 bg-white" rows={2} /> - @@ -707,7 +707,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) { 취소 {isSaving ? '처리중...' : '확인'} diff --git a/src/components/accounting/VendorManagement/VendorDetailClient.tsx b/src/components/accounting/VendorManagement/VendorDetailClient.tsx index 14606279..27b76827 100644 --- a/src/components/accounting/VendorManagement/VendorDetailClient.tsx +++ b/src/components/accounting/VendorManagement/VendorDetailClient.tsx @@ -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 (
-
@@ -283,7 +284,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail - @@ -548,7 +549,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail className="flex-1 bg-white" rows={2} /> - @@ -625,7 +626,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail 취소 {isLoading && } diff --git a/src/components/accounting/VendorManagement/actions.ts b/src/components/accounting/VendorManagement/actions.ts index fcf76534..30d5174e 100644 --- a/src/components/accounting/VendorManagement/actions.ts +++ b/src/components/accounting/VendorManagement/actions.ts @@ -19,8 +19,6 @@ import type { ApiResponse, PaginatedResponse, VendorCategory, - CLIENT_TYPE_TO_CATEGORY, - CATEGORY_TO_CLIENT_TYPE, BadDebtStatus, } from './types'; diff --git a/src/components/accounting/VendorManagement/index.ts b/src/components/accounting/VendorManagement/index.ts index 01567738..ab5e4119 100644 --- a/src/components/accounting/VendorManagement/index.ts +++ b/src/components/accounting/VendorManagement/index.ts @@ -4,6 +4,7 @@ // 클라이언트 컴포넌트 export { VendorManagementClient } from './VendorManagementClient'; +export { VendorManagementClient as VendorManagement } from './VendorManagementClient'; // 상세/수정 컴포넌트 export { VendorDetail } from './VendorDetail'; diff --git a/src/components/accounting/VendorManagement/types.ts b/src/components/accounting/VendorManagement/types.ts index ac1ef753..de532db1 100644 --- a/src/components/accounting/VendorManagement/types.ts +++ b/src/components/accounting/VendorManagement/types.ts @@ -1,6 +1,64 @@ +// ===== API 응답 타입 ===== +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +export interface PaginatedResponse { + 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 = { + SALES: 'sales', + PURCHASE: 'purchase', + BOTH: 'both', +}; + +export const CATEGORY_TO_CLIENT_TYPE: Record = { + sales: 'SALES', + purchase: 'PURCHASE', + both: 'BOTH', +}; + export const VENDOR_CATEGORY_LABELS: Record = { sales: '매출', purchase: '매입', @@ -198,6 +256,7 @@ export interface Vendor { overdueAmount: number; // 연체금액 overdueDays: number; // 연체일수 unpaidAmount: number; // 미지급 + badDebtAmount: number; // 악성채권 금액 badDebtStatus: BadDebtStatus; // 악성채권 상태 overdueToggle: boolean; // 연체 토글 badDebtToggle: boolean; // 악성채권 토글 diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx index d8a24a03..56d3ec5c 100644 --- a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx +++ b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx @@ -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 ? ( <> - @@ -202,7 +204,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps) @@ -578,7 +578,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra diff --git a/src/components/attendance/actions.ts b/src/components/attendance/actions.ts index a2030166..a22123db 100644 --- a/src/components/attendance/actions.ts +++ b/src/components/attendance/actions.ts @@ -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 : '퇴근 기록에 실패했습니다.', }; } } diff --git a/src/components/business/juil/bidding/BiddingDetailForm.tsx b/src/components/business/juil/bidding/BiddingDetailForm.tsx index 50215e66..f6d1180b 100644 --- a/src/components/business/juil/bidding/BiddingDetailForm.tsx +++ b/src/components/business/juil/bidding/BiddingDetailForm.tsx @@ -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 ? (
diff --git a/src/components/business/juil/estimates/EstimateDetailForm.tsx b/src/components/business/juil/estimates/EstimateDetailForm.tsx index 76805db4..d45c6568 100644 --- a/src/components/business/juil/estimates/EstimateDetailForm.tsx +++ b/src/components/business/juil/estimates/EstimateDetailForm.tsx @@ -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({ -
@@ -569,6 +569,7 @@ export default function EstimateDetailForm({ return (
- @@ -718,7 +719,7 @@ export default function EstimateDetailForm({ 취소 {isLoading && } diff --git a/src/components/business/juil/estimates/sections/EstimateDetailTableSection.tsx b/src/components/business/juil/estimates/sections/EstimateDetailTableSection.tsx index 468566a9..4f4452a9 100644 --- a/src/components/business/juil/estimates/sections/EstimateDetailTableSection.tsx +++ b/src/components/business/juil/estimates/sections/EstimateDetailTableSection.tsx @@ -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} > 조정 단가 적용 diff --git a/src/components/business/juil/estimates/sections/PriceAdjustmentSection.tsx b/src/components/business/juil/estimates/sections/PriceAdjustmentSection.tsx index f2938961..01e6458f 100644 --- a/src/components/business/juil/estimates/sections/PriceAdjustmentSection.tsx +++ b/src/components/business/juil/estimates/sections/PriceAdjustmentSection.tsx @@ -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} > 전체 적용 diff --git a/src/components/business/juil/item-management/ItemDetailClient.tsx b/src/components/business/juil/item-management/ItemDetailClient.tsx index 6a07e961..e86ae924 100644 --- a/src/components/business/juil/item-management/ItemDetailClient.tsx +++ b/src/components/business/juil/item-management/ItemDetailClient.tsx @@ -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' && ( <>
); - // 상태 뱃지 렌더링 - const renderStatusBadge = (status: OrderStatus) => { - return ( - - {ORDER_STATUS_LABELS[status]} - - ); - }; - return ( {/* 발주 정보 */} - - - 발주 정보 - - -
- {/* 발주번호 */} -
- - handleFieldChange('orderNumber', e.target.value)} - disabled={isViewMode} - /> -
- - {/* 발주일 (발주처) */} -
- - -
- - {/* 구분 */} -
- - -
- - {/* 상태 */} -
- - -
- - {/* 발주담당자 */} -
- - -
- - {/* 화물도착지 */} -
- - handleFieldChange('deliveryAddress', e.target.value)} - placeholder="주소명" - disabled={isViewMode} - /> -
-
-
-
+ {/* 계약 정보 */} - - - 계약 정보 - - -
- {/* 거래처명 */} -
- - -
- - {/* 현장명 */} -
- - handleFieldChange('siteName', e.target.value)} - disabled={isViewMode} - /> -
- - {/* 계약번호 */} -
- - handleFieldChange('contractNumber', e.target.value)} - disabled={isViewMode} - /> -
- - {/* 공사PM */} -
- - -
- - {/* 공사담당자 */} -
- -
- {formData.constructionManagers.map((manager, index) => ( -
- { - const newManagers = [...formData.constructionManagers]; - newManagers[index] = e.target.value; - handleFieldChange('constructionManagers', newManagers); - }} - disabled={isViewMode} - className="w-24" - /> - {isEditMode && ( - - )} -
- ))} - {isEditMode && ( - - )} -
-
-
-
-
+ {/* 발주 스케줄 (달력) */} - - - 발주 스케줄 - - - {}} - onMonthChange={handleCalendarMonthChange} - maxEventsPerDay={3} - weekStartsOn={0} - isLoading={false} - /> - - + {/* 발주 상세 - 카테고리별 섹션 */} {formData.orderCategories.map((category) => { const categorySelectedItems = selectedItems.get(category.id) || new Set(); const addCount = addCounts.get(category.id) || 1; - const categoryFilter = categoryFilters.get(category.id) || 'all'; return ( - - - {/* 왼쪽: 발주 상세, N건 선택, 삭제 */} -
- 발주 상세 - {isEditMode && ( - <> - - {categorySelectedItems.size}건 선택 - - - - )} -
- {/* 오른쪽 끝: 숫자, 추가, 카테고리, 🗑️ */} - {isEditMode && ( -
- { - setAddCounts((prev) => { - const newMap = new Map(prev); - newMap.set(category.id, parseInt(e.target.value) || 1); - return newMap; - }); - }} - className="w-16 h-8" - /> - - - -
- )} -
- -
- - - - {isEditMode && ( - - 0 && - categorySelectedItems.size === category.items.length - } - onCheckedChange={() => - handleToggleSelectAll(category.id, category.items) - } - /> - - )} - 번호 - 작업반장 - 시공투입일 - 시공완료일 - 명칭 - 제품 - 가로 - 세로 - 항목명 - 수량 - 단위 - 비고 - 이미지 - 발주일 - 계획납품일 - 실제납품일 - 상태 - - - - {category.items.length === 0 ? ( - - - 등록된 발주 품목이 없습니다. - - - ) : ( - category.items.map((item, index) => ( - - {isEditMode && ( - - - handleToggleSelection(category.id, item.id) - } - /> - - )} - {index + 1} - - {isEditMode ? ( - - ) : ( - item.workTeamLeader || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'constructionStartDate', - e.target.value - ) - } - className="h-8" - /> - ) : ( - item.constructionStartDate || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'constructionEndDate', - e.target.value - ) - } - className="h-8" - /> - ) : ( - item.constructionEndDate || '-' - )} - - - {isEditMode ? ( - - handleItemChange(category.id, item.id, 'name', e.target.value) - } - className="h-8" - /> - ) : ( - item.name || '-' - )} - - - {isEditMode ? ( - - handleItemChange(category.id, item.id, 'product', e.target.value) - } - className="h-8" - /> - ) : ( - item.product || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'width', - parseInt(e.target.value) || 0 - ) - } - className="h-8 text-right" - /> - ) : ( - item.width || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'height', - parseInt(e.target.value) || 0 - ) - } - className="h-8 text-right" - /> - ) : ( - item.height || '-' - )} - - - {isEditMode ? ( - - ) : ( - item.itemName || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'quantity', - parseInt(e.target.value) || 0 - ) - } - className="h-8 text-right" - /> - ) : ( - item.quantity - )} - - - {isEditMode ? ( - - handleItemChange(category.id, item.id, 'unit', e.target.value) - } - className="h-8 w-14" - /> - ) : ( - item.unit || '-' - )} - - - {isEditMode ? ( - - handleItemChange(category.id, item.id, 'remark', e.target.value) - } - className="h-8" - /> - ) : ( - item.remark || '-' - )} - - - {item.imageUrl ? ( - - ) : ( - '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'orderDate', - e.target.value - ) - } - className="h-8" - /> - ) : ( - item.orderDate || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'plannedDeliveryDate', - e.target.value - ) - } - className="h-8" - /> - ) : ( - item.plannedDeliveryDate || '-' - )} - - - {isEditMode ? ( - - handleItemChange( - category.id, - item.id, - 'actualDeliveryDate', - e.target.value - ) - } - className="h-8" - /> - ) : ( - item.actualDeliveryDate || '-' - )} - - - {isEditMode ? ( - - ) : ( - renderStatusBadge(item.status) - )} - - - )) - )} - {/* 합계 행 */} - {category.items.length > 0 && ( - - - 합계 - - - {category.items.reduce((sum, item) => sum + item.quantity, 0)} - - - - )} - -
-
-
-
+ { + setAddCounts((prev) => { + const newMap = new Map(prev); + newMap.set(category.id, count); + return newMap; + }); + }} + onAddItems={(count) => handleAddItems(category.id, count)} + onDeleteSelectedItems={() => handleDeleteSelectedItems(category.id)} + onDeleteAllItems={() => handleDeleteAllItems(category.id)} + onCategoryChange={(field, value) => + handleCategoryChange(category.id, field, value) + } + onItemChange={(itemId, field, value) => + handleItemChange(category.id, itemId, field, value) + } + onToggleSelection={(itemId) => handleToggleSelection(category.id, itemId)} + onToggleSelectAll={() => handleToggleSelectAll(category.id, category.items)} + /> ); })} @@ -1182,83 +214,26 @@ export default function OrderDetailForm({ )} {/* 비고 */} - - - 비고 - - -