Compare commits
55 Commits
8c0a655906
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd4bd38da | |||
|
|
68331be0ef | ||
|
|
7d369d1404 | ||
| 74e0e2bf44 | |||
| c94236e15c | |||
| 3bade70c5f | |||
| b7c2b99c68 | |||
| 563b240fbf | |||
| e75d8f9b25 | |||
| 4ea03922a3 | |||
| 295585d8b6 | |||
| e7263feecf | |||
| 8250eaf2b5 | |||
| 72a2a3e9a9 | |||
| 31f523c88f | |||
| a1fb0d4f9b | |||
| fe930b5831 | |||
| 899493a74d | |||
| 45ad99cb38 | |||
| 10c6e20db4 | |||
| 50e4c72c8a | |||
| eb18a3facb | |||
| 9fc979e135 | |||
| fa7efb7b24 | |||
|
|
bec933b3b4 | ||
|
|
1675f3edcf | ||
|
|
2fe47c86d3 | ||
|
|
00a6209347 | ||
| c18c68b6b7 | |||
| 03d129c32c | |||
| d6e3131c6a | |||
| 1d3805781c | |||
| b45c35a5e8 | |||
| b05e19e9f8 | |||
| 4331b84a63 | |||
| 0b81e9c1dd | |||
| f653960a30 | |||
| 888fae119f | |||
| f503e20030 | |||
| 0166601be8 | |||
| 83a23701a7 | |||
| bedfd1f559 | |||
| 8bcabafd08 | |||
| 5ff5093d7b | |||
|
|
23fa9c0ea2 | ||
|
|
cde9333652 | ||
|
|
7bb8699403 | ||
|
|
1bccaffe27 | ||
| 7a8d946960 | |||
| d1c530fdc1 | |||
| 0f53b407db | |||
| 0da6586bb6 | |||
| 2c87ac535a | |||
| 9ae2210388 | |||
| 33f763b48f |
@@ -107,3 +107,10 @@ fixed_tools: []
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
17
CLAUDE.md
17
CLAUDE.md
@@ -326,16 +326,19 @@ const [data, setData] = useState(() => {
|
||||
|
||||
---
|
||||
|
||||
## Backend API Analysis Policy
|
||||
## Backend API Policy
|
||||
**Priority**: 🟡
|
||||
|
||||
- Backend API 코드는 **분석만**, 직접 수정 안 함
|
||||
- 수정 필요 시 백엔드 요청 문서로 정리:
|
||||
- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
|
||||
- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
|
||||
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
|
||||
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
|
||||
- 신규 API가 필요한 경우 요청 문서로 정리:
|
||||
```markdown
|
||||
## 백엔드 API 수정 요청
|
||||
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX)
|
||||
### 현재 문제: [설명]
|
||||
### 수정 요청: [내용]
|
||||
## 백엔드 API 신규 요청
|
||||
### 엔드포인트: [HTTP METHOD /api/v1/path]
|
||||
### 목적: [설명]
|
||||
### 요청/응답 구조: [내용]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
6
Jenkinsfile
vendored
6
Jenkinsfile
vendored
@@ -17,7 +17,7 @@ pipeline {
|
||||
script {
|
||||
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
|
||||
}
|
||||
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_react', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
@@ -128,11 +128,11 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
failure {
|
||||
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 계정과목 통합 프로젝트 체크리스트
|
||||
|
||||
> 시작: 2026-03-06
|
||||
> 목표: 계정과목 마스터 통합 → 분개 흐름 통합 → 대시보드 연동
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 계정과목 마스터 강화 (백엔드)
|
||||
|
||||
### 1-1. account_codes 테이블 확장
|
||||
- [x] 마이그레이션: sub_category(중분류), depth(계층), parent_code(상위계정), department_type(부문) 추가
|
||||
- [x] AccountCode 모델 업데이트 (fillable, casts, 관계)
|
||||
- [x] AccountCodeService 확장 (계층 조회, 부문 필터 지원)
|
||||
- [x] AccountSubjectController 확장 (새 필드 지원 API)
|
||||
- [x] UpdateAccountSubjectRequest 생성
|
||||
- [x] 라우트 추가 (PUT /{id}, POST /seed-defaults)
|
||||
|
||||
### 1-2. 표준 계정과목표 시드 데이터 (더존 Smart A 기준)
|
||||
- [x] 시드 데이터 정의 (대분류 5개 + 중분류 12개 + 소분류 111개 = 128건)
|
||||
- [x] seedDefaults() API 엔드포인트 (별도 Seeder 대신 API로 제공)
|
||||
- [x] 기존 데이터와 충돌 방지 로직 (tenant_id+code 중복 시 skip)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 프론트 공용 컴포넌트
|
||||
|
||||
### 2-1. 공용 계정과목 설정 모달 (리스트 페이지용 - CRUD)
|
||||
- [x] AccountSubjectSettingModal 공용 컴포넌트 생성 (src/components/accounting/common/)
|
||||
- [x] 기존 GeneralJournalEntry/AccountSubjectSettingModal 코드 이관 + 확장
|
||||
- [x] 계층 표시 (depth별 들여쓰기: 대→중→소)
|
||||
- [x] 부문 컬럼 추가
|
||||
- [x] "기본 계정과목 생성" 버튼 (seedDefaults API 연동)
|
||||
|
||||
### 2-2. 공용 계정과목 Select (세부 페이지/모달용 - 조회/선택)
|
||||
- [x] AccountSubjectSelect 공용 컴포넌트 생성
|
||||
- [x] DB 마스터 API 호출로 옵션 로드 (selectable=true, isActive=true)
|
||||
- [x] 활성 계정과목만 표시
|
||||
- [x] "[코드] 계정과목명" 형태 표시 (예: [51100] 복리후생비(제조))
|
||||
- [x] 분류별 필터 지원 (props: category, subCategory, departmentType)
|
||||
|
||||
### 2-3. 공용 타입/API 함수
|
||||
- [x] 공용 타입 정의 (src/components/accounting/common/types.ts)
|
||||
- [x] 공용 actions.ts (계정과목 CRUD + seedDefaults + update API)
|
||||
- [x] index.ts 배럴 파일 생성
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 7개 모듈 전환 (프론트)
|
||||
|
||||
### 3-1. 일반전표입력
|
||||
- [x] 전용 AccountSubjectSettingModal → 공용 컴포넌트로 교체
|
||||
- [x] 전용 타입/API → 공용으로 교체 (actions.ts, types.ts 정리)
|
||||
- [x] ManualJournalEntryModal: getAccountSubjects → 공용 actions
|
||||
- [x] JournalEditModal: getAccountSubjects → 공용 actions
|
||||
- [x] 전용 AccountSubjectSettingModal.tsx 삭제
|
||||
|
||||
### 3-2. 세금계산서관리
|
||||
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
|
||||
|
||||
### 3-3. 카드사용내역
|
||||
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
|
||||
- [x] ManualInputModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
|
||||
- [x] index.tsx 인라인 Select → AccountSubjectSelect
|
||||
- 참고: ACCOUNT_SUBJECT_OPTIONS 상수는 엑셀 변환에서 기존 데이터 호환용으로 유지
|
||||
|
||||
### 3-4. 입금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
|
||||
### 3-5. 출금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
|
||||
|
||||
### 3-6. 미지급비용
|
||||
- [x] ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect (category="expense" 필터)
|
||||
|
||||
### 3-7. 매출관리 — 보류 (매출유형 분류이며 계정과목 코드가 아님)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 분개 흐름 통합 (백엔드)
|
||||
|
||||
### 4-1. source_type 확장
|
||||
- [x] JournalEntry 모델에 SOURCE_CARD_TRANSACTION, SOURCE_TAX_INVOICE 상수 추가
|
||||
- [x] source_type은 string(30)이므로 enum 마이그레이션 불필요 (상수 추가만으로 완료)
|
||||
|
||||
### 4-2. 세금계산서 분개 통합
|
||||
- [x] JournalSyncService 생성 (공용 분개 CRUD + expense 동기화)
|
||||
- [x] TaxInvoiceController에 journal CRUD 메서드 추가 (get/store/delete)
|
||||
- [x] 라우트 추가: GET/POST/PUT/DELETE /api/v1/tax-invoices/{id}/journal-entries
|
||||
- [x] source_type = 'tax_invoice', source_key = 'tax_invoice_{id}'
|
||||
|
||||
### 4-3. 카드사용내역 분개 통합
|
||||
- [x] CardTransactionController에 journal CRUD 메서드 추가 (get/store)
|
||||
- [x] 라우트 추가: GET/POST /api/v1/card-transactions/{id}/journal-entries
|
||||
- [x] 카드 items → 차변(비용계정) + 대변(미지급금) 자동 변환
|
||||
- [x] source_type = 'card_transaction', source_key = 'card_{id}'
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 대시보드 연동
|
||||
|
||||
### 5-1. expense_accounts 동기화 확장
|
||||
- [x] SyncsExpenseAccounts 트레이트 생성 (app/Traits/)
|
||||
- [x] GeneralJournalEntryService → 트레이트 사용으로 전환
|
||||
- [x] JournalSyncService에서 트레이트 사용 (세금계산서/카드 분개 저장 시 자동 동기화)
|
||||
- [x] source_type별 payment_method 자동 결정 (card_transaction → PAYMENT_CARD)
|
||||
- [x] 모든 source_type에서 복리후생비/접대비 감지
|
||||
|
||||
### 5-2. 대시보드 집계 검증
|
||||
- [x] expense_accounts에 journal_entry_id/journal_entry_line_id 연결 (기존 마이그레이션 활용)
|
||||
- [x] CEO 대시보드는 expense_accounts 테이블 기준 집계 → 모든 source_type 반영됨
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서 및 의존성
|
||||
|
||||
```
|
||||
Phase 1 (백엔드 마스터 강화)
|
||||
↓
|
||||
Phase 2 (프론트 공용 컴포넌트)
|
||||
↓
|
||||
Phase 3 (7개 모듈 전환) — 모듈별 독립, 병렬 가능
|
||||
↓
|
||||
Phase 4 (분개 흐름 통합) — Phase 3과 병렬 가능
|
||||
↓
|
||||
Phase 5 (대시보드 연동)
|
||||
```
|
||||
250
claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md
Normal file
250
claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# 프론트엔드 주간 구현내역 (2026-03-02 ~ 2026-03-08)
|
||||
|
||||
> 총 커밋 59개 (feat 30 / fix 17 / refactor 3 / chore 3 / merge 1 / 기타 5)
|
||||
|
||||
---
|
||||
|
||||
## 1. 품질관리 — Mock→API 전환 + 검사 모달/문서 대폭 개선
|
||||
|
||||
**커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b (9개)
|
||||
**변경 규모**: +2,210 / -566 라인
|
||||
|
||||
### 1-1. API 전환
|
||||
- `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거
|
||||
- 엔드포인트: `/api/v1/quality/documents`, `/api/v1/quality/performance-reports`
|
||||
- snake_case → camelCase 변환 함수 구현
|
||||
- InspectionFormData에 `clientId`, `inspectorId`, `receptionDate` 필드 추가
|
||||
|
||||
### 1-2. 검사 모달 개선 (InspectionInputModal)
|
||||
- 일괄 합격/초기화 토글 버튼 추가
|
||||
- 시공 치수 필드 (너비/높이) 추가
|
||||
- 변경사유 입력 필드 추가
|
||||
- 사진 첨부 (최대 2장, base64)
|
||||
- 이전/다음 개소 네비게이션 + 자동저장
|
||||
- 레거시 검사 데이터 통합 (합격/불합격/진행중/미완)
|
||||
|
||||
### 1-3. 수주선택 모달 (OrderSelectModal)
|
||||
- 발주처(clientName) 컬럼 추가
|
||||
- 동일 발주처 + 동일 모델 필터링 제약
|
||||
- `SearchableSelectionModal`에 `isItemDisabled` 콜백 추가 (공통 컴포넌트 확장)
|
||||
- 비활성 항목 스타일링 + 전체선택 시 비활성 항목 제외
|
||||
|
||||
### 1-4. 제품검사 성적서 (FqcDocumentContent)
|
||||
- 8컬럼 동적 렌더링: No / 검사항목 / 세부항목 / 검사기준 / 검사방법 / 검사주기 / 측정값 / 판정
|
||||
- rowSpan 병합: 카테고리 단일 + method+frequency 복합 병합
|
||||
- measurement_type별 처리: checkbox → 양호/불량, numeric → 숫자입력, none → 비활성
|
||||
- FQC 모드 우선 + legacy fallback 패턴
|
||||
|
||||
### 1-5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규
|
||||
- 양식 기반 동적 렌더링 (template_id: 66)
|
||||
- 결재라인 + 기본정보(7개) + 입력섹션(4개) + 사전통보 테이블
|
||||
- EAV 데이터 구조: section_id, column_id, row_index, field_key, field_value
|
||||
- EAV 문서 없을 때 legacy fallback 적용
|
||||
|
||||
### 1-6. 수주 연결 동기화
|
||||
- order_ids 배열 매핑 (다중 수주 지원)
|
||||
- 개소별 inspectionData 서버 저장
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/quality/InspectionManagement/actions.ts`
|
||||
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx`
|
||||
- `src/components/quality/InspectionManagement/OrderSelectModal.tsx`
|
||||
- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규)
|
||||
- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규)
|
||||
- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 2. 문서스냅샷 시스템 (Lazy Snapshot) — 신규 기능
|
||||
|
||||
**커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7 (5개)
|
||||
**변경 규모**: +300 라인
|
||||
|
||||
### 개요
|
||||
문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템.
|
||||
|
||||
### 2-1. 수동 캡처 (저장 시)
|
||||
- 검사성적서(InspectionReportModal): `contentWrapperRef.innerHTML` 캡처 → 저장 시 `rendered_html` 파라미터 포함
|
||||
- 작업일지(WorkLogModal): 동일 패턴
|
||||
- 수입검사(ImportInspectionInputModal): 오프스크린 렌더링 방식
|
||||
|
||||
### 2-2. Lazy Snapshot (조회 시 자동 캡처)
|
||||
- 조건: `rendered_html === NULL`인 문서 조회 시
|
||||
- 동작: 500ms 지연 → innerHTML 캡처 → 백그라운드 PATCH
|
||||
- 비차단(non-blocking): UI에 영향 없이 백그라운드 처리
|
||||
- `patchDocumentSnapshot()` 서버 액션으로 전송
|
||||
|
||||
### 2-3. 오프스크린 렌더링 유틸리티
|
||||
- `src/lib/utils/capture-rendered-html.tsx` (신규)
|
||||
- 폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처
|
||||
- readOnly 모드 자동 캡처 useEffect 제거 (불필요한 PUT 요청 방지)
|
||||
|
||||
### 적용 범위
|
||||
| 문서 | 수동 캡처 | Lazy Snapshot |
|
||||
|------|-----------|---------------|
|
||||
| 검사성적서 | ✅ | ✅ |
|
||||
| 작업일지 | ✅ | ✅ |
|
||||
| 수입검사 | ✅ (오프스크린) | - |
|
||||
| 제품검사 요청서 | ✅ | ✅ |
|
||||
|
||||
### 주요 파일
|
||||
- `src/lib/utils/capture-rendered-html.tsx` (신규)
|
||||
- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx`
|
||||
- `src/components/production/WorkerScreen/WorkLogModal.tsx`
|
||||
- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx`
|
||||
- `src/components/production/WorkOrders/actions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 3. 생산지시 — API 연동 + 작업자 화면 + 중간검사
|
||||
|
||||
**커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1 (8개)
|
||||
**변경 규모**: +2,000 라인
|
||||
|
||||
### 3-1. 생산지시 목록/상세 API 연동
|
||||
- Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴)
|
||||
- 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API)
|
||||
- WorkOrder 상태 배지 6단계: 미배정→배정→작업중→검사→완료→출하
|
||||
- BOM null 상태 처리
|
||||
|
||||
### 3-2. 절곡 중간검사 입력 모달 (InspectionInputModal)
|
||||
- 7개 제품 항목 통합 폼
|
||||
- 제품 ID 자동 매칭: 정규화 → 키워드 → 인덱스 fallback (3단계)
|
||||
- cellValues 구조: `{bending_state, length, width, spacing}`
|
||||
- PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유
|
||||
|
||||
### 3-3. 자재투입 모달 (MaterialInputModal)
|
||||
- 동일 자재 다중 BOM 그룹 LOT 독립 관리
|
||||
- `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑
|
||||
- 카테고리 정렬: 가이드레일(1) → 하단마감재(2) → 셔터박스(3) → 연기차단재(4)
|
||||
- FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적
|
||||
- 번호 배지(①②③) + partType 배지
|
||||
|
||||
### 3-4. 공정 단계 검사범위 설정 (InspectionScope) — 신규
|
||||
- 전수검사 / 샘플링 / 그룹 3가지 타입
|
||||
- 샘플링 시 샘플 수(n) 입력 지원
|
||||
- StepForm 컴포넌트에 UI 추가, options JSON으로 API 저장
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/production/ProductionOrders/actions.ts`, `types.ts`
|
||||
- `src/components/production/WorkerScreen/InspectionInputModal.tsx`
|
||||
- `src/components/production/WorkerScreen/MaterialInputModal.tsx`
|
||||
- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규)
|
||||
- `src/components/process-management/StepForm.tsx`
|
||||
- `src/types/process.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4. 출하/배차 — 배차 다중행 + 차량관리 API + 출고관리
|
||||
|
||||
**커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3 (6개)
|
||||
**변경 규모**: +2,400 / -1,100 라인
|
||||
|
||||
### 4-1. 배차정보 다중 행 API 연동
|
||||
- `vehicle_dispatches` 배열 지원 (기존 단일 배차 → 다중 배차)
|
||||
- transform 함수: `transformApiToDetail`, `transformCreateFormToApi`, `transformEditFormToApi` 갱신
|
||||
- 레거시 단일 배차 필드 하위호환 유지
|
||||
|
||||
### 4-2. 배차차량관리 Mock→API 전환
|
||||
- `executePaginatedAction` + `buildApiUrl` 패턴 적용
|
||||
- `transformToListItem()` / `transformToDetail()` snake_case → camelCase 변환
|
||||
- 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page`
|
||||
|
||||
### 4-3. 출고관리 목록 필드 매핑
|
||||
- `writer_name`, `writer_id`, `delivery_date` 등 5개 필드 API 매핑 추가
|
||||
- `OrderInfoApiData` 타입으로 주문 연결 정보 처리
|
||||
|
||||
### 4-4. 배차 상세/수정 레이아웃 개선
|
||||
- 기본정보 그리드: 1열 → 2×4열 레이아웃
|
||||
|
||||
### 4-5. 출하관리 캘린더
|
||||
- 기본 뷰: day → week-time 변경
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/outbound/ShipmentManagement/actions.ts`
|
||||
- `src/components/outbound/VehicleDispatchManagement/actions.ts`
|
||||
- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`, `ShipmentEdit.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 5. 전자결재 — 결재함 확장 + 연결문서
|
||||
|
||||
**커밋**: 181352d7, 72cf5d86 (2개)
|
||||
**변경 규모**: +458 / -127 라인
|
||||
|
||||
### 5-1. 결재함 기능 확장
|
||||
- 결재함 API 연동:
|
||||
- `GET /api/v1/approvals/inbox` — 결재함 목록
|
||||
- `GET /api/v1/approvals/inbox/summary` — 통계
|
||||
- `POST /api/v1/approvals/{id}/approve` — 승인
|
||||
- `POST /api/v1/approvals/{id}/reject` — 반려
|
||||
- 문서 상태: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED
|
||||
|
||||
### 5-2. 연결문서 기능 (LinkedDocumentContent) — 신규
|
||||
- 검사성적서, 작업일지 등을 결재 문서에 연결하여 렌더링
|
||||
- DocumentHeader 컴포넌트 활용, 결재라인/상태배지/메타 정보 표시
|
||||
|
||||
### 5-3. 모바일 반응형
|
||||
- AuthenticatedLayout: 사이드바/메인 콘텐츠 모바일 대응
|
||||
- HeaderFavoritesBar 전면 재설계
|
||||
- SearchableSelectionModal HTML 유효성 수정
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/approval/ApprovalBox/actions.ts`, `index.tsx`, `types.ts`
|
||||
- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규)
|
||||
- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx`
|
||||
- `src/layouts/AuthenticatedLayout.tsx`
|
||||
- `src/components/layout/HeaderFavoritesBar.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 6. CEO 대시보드 — API 연동 + 섹션 확장 + 리팩토링
|
||||
|
||||
**커밋**: 9ad4c8ee, 23fa9c0e, cde93336, 4e179d2e, db84d679, 1bccaffe, bec933b3, 1675f3ed (8개)
|
||||
**별도 문서**: `claudedocs/dashboard/[VERIFY-2026-03-06] ceo-dashboard-data-flow-verification.md`
|
||||
|
||||
### 주요 변경
|
||||
- SummaryNavBar 추가 (상단 요약 데이터 네비게이션)
|
||||
- 접대비/복리후생비/매출채권/캘린더 섹션 개선
|
||||
- 컴포넌트 분리 및 모달/섹션 리팩토링
|
||||
- mockData/modalConfigs 정리
|
||||
- API 연동 강화 (회계/결재/HR 섹션)
|
||||
- `invalidateDashboard()` 시스템 추가 (5개 도메인 연동)
|
||||
|
||||
---
|
||||
|
||||
## 7. 회계 — 계정과목 공통화 + 어음 리팩토링
|
||||
|
||||
**커밋**: 7d369d14, 1691337f, a4f99ae3(일부) (3개)
|
||||
**별도 문서**: `claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md`
|
||||
|
||||
### 주요 변경
|
||||
- AccountSubjectSelect 공통 컴포넌트: 7개 페이지에 일괄 적용
|
||||
- 매출/매입/부실채권/일일보고 UI 개선
|
||||
- BillManagement 섹션 분리: 11개 섹션 컴포넌트 + 커스텀 훅(`useBillForm`, `useBillConditions`)
|
||||
|
||||
---
|
||||
|
||||
## 8. 기타
|
||||
|
||||
### E2E 테스트
|
||||
- `f5bdc5ba`: 11개 FAIL 시나리오 수정 후 전체 PASS
|
||||
|
||||
### 인프라
|
||||
- `f9eea0c9`, `c18c68b6`: Slack 알림 채널 분리 (product_infra → deploy_react)
|
||||
- `888fae11`: next dev에서 --turbo 플래그 제거
|
||||
|
||||
---
|
||||
|
||||
## 문서 현황
|
||||
|
||||
| 도메인 | 문서 상태 |
|
||||
|--------|----------|
|
||||
| 품질관리 Mock→API | ✅ 본 문서 §1 |
|
||||
| 문서스냅샷 (Lazy Snapshot) | ✅ 본 문서 §2 |
|
||||
| 생산지시 API 연동 | ✅ 본 문서 §3 |
|
||||
| 출하/배차 API 연동 | ✅ 본 문서 §4 |
|
||||
| 전자결재 확장 | ✅ 본 문서 §5 |
|
||||
| CEO 대시보드 | ✅ 별도 문서 존재 |
|
||||
| 계정과목 공통화 | ✅ 별도 문서 존재 |
|
||||
| 백엔드 구현내역 | ✅ 일별 문서 존재 (03-02 ~ 03-08) |
|
||||
498
claudedocs/[PLAN-2026-03-06] account-subject-unification.md
Normal file
498
claudedocs/[PLAN-2026-03-06] account-subject-unification.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# 계정과목 통합 기획서
|
||||
|
||||
> 작성일: 2026-03-06
|
||||
> 상태: 진행중
|
||||
> 관련: `claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 및 목표
|
||||
|
||||
### 문제점
|
||||
현재 계정과목이 **7개 모듈에서 각자 하드코딩**으로 관리되고 있음.
|
||||
- 일반전표만 DB 마스터(account_codes) 사용, 나머지는 프론트 상수 배열
|
||||
- 계정과목 등록은 일반전표 설정에서만 가능
|
||||
- 분개 데이터가 3개 테이블에 분산 (journal_entries, hometax_invoice_journals, barobill_card_transactions)
|
||||
- CEO 대시보드 비용 집계가 일반전표 분개에서만 작동
|
||||
|
||||
### 목표
|
||||
1. **계정과목 마스터 통합**: 하나의 DB 테이블, 전 모듈 공유
|
||||
2. **공용 컴포넌트**: 설정 모달(CRUD) + Select(조회) 2개로 전 모듈 대응
|
||||
3. **분개 흐름 통합**: 모든 분개 → journal_entries 한 곳에 저장
|
||||
4. **대시보드 정확도**: 어디서 분개하든 비용 집계 정상 작동
|
||||
|
||||
### 회계담당자 요구사항
|
||||
- 계정과목을 번호 + 명칭으로 구분 (예: 5201 급여)
|
||||
- 제조/회계 동일 명칭이지만 번호로 구분 가능해야 함
|
||||
- 등록하면 전체 공유, 개별 등록도 가능
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 상태 (AS-IS)
|
||||
|
||||
### 2.1 모듈별 계정과목 관리
|
||||
|
||||
| 모듈 | 소스 | 옵션 수 | 필드명 | API 필드 |
|
||||
|------|------|---------|--------|----------|
|
||||
| 일반전표입력 | DB 마스터 | 동적 | accountSubjectId | account_subject_id |
|
||||
| 세금계산서관리 | 프론트 상수 | 11개 | accountSubject | account_subject |
|
||||
| 카드사용내역 | 프론트 상수 | 16개 | accountSubject | account_code |
|
||||
| 입금관리 | 프론트 상수 | ~11개 | depositType | account_code |
|
||||
| 출금관리 | 프론트 상수 | ~11개 | withdrawalType | account_code |
|
||||
| 미지급비용 | 프론트 상수 | 9개 | accountSubject | account_code |
|
||||
| 매출관리 | 프론트 상수 | 8개 | accountSubject | account_code |
|
||||
|
||||
### 2.2 분개 저장 위치
|
||||
|
||||
| 소스 | 저장 테이블 | expense_accounts 동기화 |
|
||||
|------|-----------|----------------------|
|
||||
| 일반전표 (수기) | journal_entries + journal_entry_lines | O |
|
||||
| 일반전표 (입출금 연동) | journal_entries + journal_entry_lines | O |
|
||||
| 세금계산서 분개 | hometax_invoice_journals (별도) | X |
|
||||
| 카드 계정과목 태그 | barobill_card_transactions.account_code | X |
|
||||
|
||||
### 2.3 백엔드 현재 테이블
|
||||
|
||||
```sql
|
||||
-- account_codes (계정과목 마스터 - 일반전표만 사용)
|
||||
id, tenant_id, code(10), name(100), category(enum), sort_order, is_active
|
||||
|
||||
-- journal_entries (분개 헤더)
|
||||
id, tenant_id, entry_no, entry_date, entry_type, description,
|
||||
total_debit, total_credit, status, source_type, source_key
|
||||
|
||||
-- journal_entry_lines (분개 상세)
|
||||
id, journal_entry_id, tenant_id, line_no, account_code, account_name,
|
||||
side(debit/credit), amount, trading_partner_id, trading_partner_name, description
|
||||
|
||||
-- hometax_invoice_journals (세금계산서 분개 - 별도)
|
||||
id, tenant_id, hometax_invoice_id, nts_confirm_num,
|
||||
dc_type, account_code, account_name, debit_amount, credit_amount, ...
|
||||
|
||||
-- barobill_card_transactions (카드 거래)
|
||||
..., account_code, ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표 상태 (TO-BE)
|
||||
|
||||
### 3.1 통합 구조
|
||||
|
||||
```
|
||||
[계정과목 마스터]
|
||||
account_codes 테이블 (확장)
|
||||
├── code: "5201"
|
||||
├── name: "급여"
|
||||
├── category: "expense"
|
||||
├── sub_category: "selling_admin" (판관비)
|
||||
├── parent_code: "52" (상위 그룹)
|
||||
├── depth: 3 (대=1, 중=2, 소=3)
|
||||
└── department_type: "common" (공통/제조/관리)
|
||||
|
||||
[분개 통합]
|
||||
journal_entries (source_type으로 출처 구분)
|
||||
├── source_type: 'manual' ← 수기 전표
|
||||
├── source_type: 'bank_transaction' ← 입출금 연동
|
||||
├── source_type: 'tax_invoice' ← 세금계산서 (신규)
|
||||
└── source_type: 'card_transaction' ← 카드사용내역 (신규)
|
||||
|
||||
[프론트 공용 컴포넌트]
|
||||
AccountSubjectSettingModal → 리스트 페이지에서 CRUD
|
||||
AccountSubjectSelect → 세부 페이지/모달에서 선택
|
||||
```
|
||||
|
||||
### 3.2 데이터 흐름 (TO-BE)
|
||||
|
||||
```
|
||||
계정과목 등록 (어느 페이지에서든)
|
||||
→ account_codes 테이블에 저장
|
||||
→ 전 모듈에서 즉시 사용 가능
|
||||
|
||||
분개 입력 (어느 모듈에서든)
|
||||
→ journal_entries + journal_entry_lines에 저장
|
||||
→ account_code는 account_codes 마스터 참조
|
||||
→ expense_accounts 자동 동기화 (복리후생비/접대비)
|
||||
→ CEO 대시보드에 자동 반영
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase별 세부 구현 계획
|
||||
|
||||
### Phase 1: 백엔드 마스터 강화
|
||||
|
||||
#### 1-1. account_codes 테이블 확장 마이그레이션
|
||||
|
||||
```php
|
||||
// database/migrations/2026_03_06_100000_enhance_account_codes_table.php
|
||||
Schema::table('account_codes', function (Blueprint $table) {
|
||||
$table->string('sub_category', 50)->nullable()->after('category')
|
||||
->comment('중분류 (current_asset, fixed_asset, selling_admin, cogs 등)');
|
||||
$table->string('parent_code', 10)->nullable()->after('sub_category')
|
||||
->comment('상위 계정과목 코드 (계층 구조)');
|
||||
$table->tinyInteger('depth')->default(3)->after('parent_code')
|
||||
->comment('계층 깊이 (1=대분류, 2=중분류, 3=소분류)');
|
||||
$table->string('department_type', 20)->default('common')->after('depth')
|
||||
->comment('부문 (common=공통, manufacturing=제조, admin=관리)');
|
||||
$table->string('description', 500)->nullable()->after('department_type')
|
||||
->comment('계정과목 설명');
|
||||
});
|
||||
```
|
||||
|
||||
**sub_category 값 목록:**
|
||||
|
||||
| category | sub_category | 한글 |
|
||||
|----------|-------------|------|
|
||||
| asset | current_asset | 유동자산 |
|
||||
| asset | fixed_asset | 비유동자산 |
|
||||
| liability | current_liability | 유동부채 |
|
||||
| liability | long_term_liability | 비유동부채 |
|
||||
| capital | - | 자본 |
|
||||
| revenue | sales_revenue | 매출 |
|
||||
| revenue | other_revenue | 영업외수익 |
|
||||
| expense | cogs | 매출원가 |
|
||||
| expense | selling_admin | 판매비와관리비 |
|
||||
| expense | other_expense | 영업외비용 |
|
||||
|
||||
**department_type 값:**
|
||||
- `common`: 공통 (모든 부문에서 사용)
|
||||
- `manufacturing`: 제조 (매출원가 계정)
|
||||
- `admin`: 관리 (판관비 계정)
|
||||
|
||||
#### 1-2. AccountCode 모델 업데이트
|
||||
|
||||
```php
|
||||
// app/Models/Tenants/AccountCode.php
|
||||
protected $fillable = [
|
||||
'tenant_id', 'code', 'name', 'category',
|
||||
'sub_category', 'parent_code', 'depth', 'department_type',
|
||||
'description', 'sort_order', 'is_active',
|
||||
];
|
||||
|
||||
// 상수
|
||||
const DEPT_COMMON = 'common';
|
||||
const DEPT_MANUFACTURING = 'manufacturing';
|
||||
const DEPT_ADMIN = 'admin';
|
||||
|
||||
const DEPTH_MAJOR = 1; // 대분류
|
||||
const DEPTH_MIDDLE = 2; // 중분류
|
||||
const DEPTH_MINOR = 3; // 소분류
|
||||
```
|
||||
|
||||
#### 1-3. AccountCodeService 확장
|
||||
|
||||
기존 CRUD에 추가:
|
||||
- `getHierarchical()`: 계층 구조 조회 (대-중-소 트리)
|
||||
- `getByCategory(category, sub_category?)`: 분류별 조회
|
||||
- `getByDepartment(department_type)`: 부문별 조회
|
||||
- 필터: category, sub_category, department_type, depth, search, is_active
|
||||
|
||||
#### 1-4. AccountSubjectController 확장
|
||||
|
||||
기존 엔드포인트 유지 + 확장:
|
||||
```
|
||||
GET /api/v1/account-subjects ← 기존 (필터 파라미터 확장)
|
||||
?category=expense
|
||||
&sub_category=selling_admin
|
||||
&department_type=common
|
||||
&depth=3
|
||||
&search=급여
|
||||
&is_active=true
|
||||
&hierarchical=true ← 계층 구조 응답 옵션
|
||||
|
||||
POST /api/v1/account-subjects ← 기존 (새 필드 추가)
|
||||
PATCH /api/v1/account-subjects/{id} ← 신규 (수정)
|
||||
PATCH /api/v1/account-subjects/{id}/status ← 기존
|
||||
DELETE /api/v1/account-subjects/{id} ← 기존
|
||||
|
||||
POST /api/v1/account-subjects/seed-defaults ← 신규 (기본 계정과목표 일괄 생성)
|
||||
```
|
||||
|
||||
#### 1-5. 표준 계정과목표 시드 데이터
|
||||
|
||||
```
|
||||
1xxx 자산
|
||||
11xx 유동자산
|
||||
1101 현금
|
||||
1102 보통예금
|
||||
1103 당좌예금
|
||||
1110 매출채권(외상매출금)
|
||||
1120 선급금
|
||||
1130 미수금
|
||||
1140 가지급금
|
||||
12xx 비유동자산
|
||||
1201 토지
|
||||
1202 건물
|
||||
1210 기계장치
|
||||
1220 차량운반구
|
||||
1230 비품
|
||||
1240 보증금
|
||||
|
||||
2xxx 부채
|
||||
21xx 유동부채
|
||||
2101 매입채무(외상매입금)
|
||||
2102 미지급금
|
||||
2103 선수금
|
||||
2104 예수금
|
||||
2110 부가세예수금
|
||||
2120 부가세대급금
|
||||
22xx 비유동부채
|
||||
2201 장기차입금
|
||||
|
||||
3xxx 자본
|
||||
31xx 자본금
|
||||
3101 자본금
|
||||
32xx 잉여금
|
||||
3201 이익잉여금
|
||||
|
||||
4xxx 수익
|
||||
41xx 매출
|
||||
4101 제품매출
|
||||
4102 상품매출
|
||||
4103 부품매출
|
||||
4104 용역매출
|
||||
4105 공사매출
|
||||
4106 임대수익
|
||||
42xx 영업외수익
|
||||
4201 이자수익
|
||||
4202 외환차익
|
||||
|
||||
5xxx 비용
|
||||
51xx 매출원가 (제조)
|
||||
5101 재료비 ← department: manufacturing
|
||||
5102 노무비 ← department: manufacturing
|
||||
5103 외주가공비 ← department: manufacturing
|
||||
52xx 판매비와관리비 (관리)
|
||||
5201 급여 ← department: admin
|
||||
5202 복리후생비 ← department: admin
|
||||
5203 접대비 ← department: admin
|
||||
5204 세금과공과 ← department: admin
|
||||
5205 감가상각비 ← department: admin
|
||||
5206 임차료 ← department: admin
|
||||
5207 보험료(4대보험) ← department: admin
|
||||
5208 통신비 ← department: admin
|
||||
5209 수도광열비 ← department: admin
|
||||
5210 소모품비 ← department: admin
|
||||
5211 여비교통비 ← department: admin
|
||||
5212 차량유지비 ← department: admin
|
||||
5213 운반비 ← department: admin
|
||||
5214 재료비 ← department: admin (관리부문)
|
||||
5220 경비 ← department: admin
|
||||
53xx 영업외비용
|
||||
5301 이자비용
|
||||
5302 외환차손
|
||||
5310 배당금지급
|
||||
```
|
||||
|
||||
기존 하드코딩 옵션과의 매핑:
|
||||
|
||||
| 기존 하드코딩 (영문 키워드) | 매핑될 계정코드 |
|
||||
|---------------------------|---------------|
|
||||
| purchasePayment (매입대금) | 2101 매입채무 |
|
||||
| advance (선급금) | 1120 선급금 |
|
||||
| suspense (가지급금) | 1140 가지급금 |
|
||||
| rent (임차료) | 5206 임차료 |
|
||||
| salary (급여) | 5201 급여 |
|
||||
| insurance (4대보험) | 5207 보험료 |
|
||||
| tax (세금) | 5204 세금과공과 |
|
||||
| utilities (공과금) | 5209 수도광열비 |
|
||||
| expenses (경비) | 5220 경비 |
|
||||
| salesRevenue (매출수금) | 4101~4106 매출 |
|
||||
| accountsReceivable (외상매출금) | 1110 매출채권 |
|
||||
| accountsPayable (외상매입금) | 2101 매입채무 |
|
||||
| salesVat (부가세예수금) | 2110 부가세예수금 |
|
||||
| purchaseVat (부가세대급금) | 2120 부가세대급금 |
|
||||
| cashAndDeposits (현금및예금) | 1101~1103 현금/예금 |
|
||||
| advanceReceived (선수금) | 2103 선수금 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 프론트 공용 컴포넌트
|
||||
|
||||
#### 2-1. 파일 구조
|
||||
|
||||
```
|
||||
src/components/accounting/common/
|
||||
├── types.ts # 공용 타입 정의
|
||||
├── actions.ts # 공용 계정과목 API 함수
|
||||
├── AccountSubjectSettingModal.tsx # 설정 모달 (CRUD)
|
||||
└── AccountSubjectSelect.tsx # Select 컴포넌트 (조회/선택)
|
||||
```
|
||||
|
||||
#### 2-2. 공용 타입 (types.ts)
|
||||
|
||||
```typescript
|
||||
export interface AccountSubject {
|
||||
id: string;
|
||||
code: string; // "5201"
|
||||
name: string; // "급여"
|
||||
category: AccountCategory; // 'asset' | 'liability' | 'capital' | 'revenue' | 'expense'
|
||||
subCategory: string | null;
|
||||
parentCode: string | null;
|
||||
depth: number; // 1=대, 2=중, 3=소
|
||||
departmentType: string; // 'common' | 'manufacturing' | 'admin'
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// Select에서 표시할 때: `[${code}] ${name}` → "[5201] 급여"
|
||||
```
|
||||
|
||||
#### 2-3. 공용 actions.ts
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
// 계정과목 조회 (Select용 - 활성만)
|
||||
export async function getAccountSubjects(params?)
|
||||
|
||||
// 계정과목 CRUD (설정 모달용)
|
||||
export async function createAccountSubject(data)
|
||||
export async function updateAccountSubject(id, data)
|
||||
export async function updateAccountSubjectStatus(id, isActive)
|
||||
export async function deleteAccountSubject(id)
|
||||
|
||||
// 기본 계정과목표 일괄 생성
|
||||
export async function seedDefaultAccountSubjects()
|
||||
```
|
||||
|
||||
#### 2-4. AccountSubjectSettingModal (설정 모달)
|
||||
|
||||
기존 GeneralJournalEntry/AccountSubjectSettingModal 기반 확장:
|
||||
- 계층 구조 표시 (번호대별 그룹핑 또는 들여쓰기)
|
||||
- 대분류/중분류/부문 필터
|
||||
- 등록: 코드 + 명칭 + 분류 + 중분류 + 부문
|
||||
- 수정: 명칭, 분류, 상태
|
||||
- 삭제: 미사용 계정만
|
||||
- "기본 계정과목표 불러오기" 버튼 (초기 세팅용)
|
||||
|
||||
#### 2-5. AccountSubjectSelect (Select 컴포넌트)
|
||||
|
||||
```typescript
|
||||
interface AccountSubjectSelectProps {
|
||||
value: string; // 선택된 계정과목 code
|
||||
onValueChange: (code: string) => void;
|
||||
category?: AccountCategory; // 특정 분류만 표시
|
||||
subCategory?: string; // 특정 중분류만 표시
|
||||
departmentType?: string; // 특정 부문만 표시
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm';
|
||||
}
|
||||
```
|
||||
|
||||
사용 예시:
|
||||
```tsx
|
||||
// 세금계산서 분개 - 전체 계정과목
|
||||
<AccountSubjectSelect value={row.accountCode} onValueChange={...} />
|
||||
|
||||
// 카드내역 - 비용 계정만
|
||||
<AccountSubjectSelect value={...} onValueChange={...} category="expense" />
|
||||
|
||||
// 입금관리 - 수익 + 자산 계정
|
||||
<AccountSubjectSelect value={...} onValueChange={...} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 7개 모듈 전환
|
||||
|
||||
각 모듈에서:
|
||||
1. 하드코딩 ACCOUNT_SUBJECT_OPTIONS 상수 **제거**
|
||||
2. Radix Select → **AccountSubjectSelect** 교체
|
||||
3. 리스트 페이지에 **설정 모달 버튼** 추가 (필요한 곳만)
|
||||
4. API 저장 시 영문 키워드 → **계정코드(숫자)** 로 변경
|
||||
|
||||
#### 데이터 마이그레이션 고려
|
||||
|
||||
기존 데이터의 영문 키워드를 숫자 코드로 변환하는 마이그레이션 필요:
|
||||
```php
|
||||
// 예: barobill_card_transactions.account_code
|
||||
// 'salary' → '5201'
|
||||
// 'rent' → '5206'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 분개 흐름 통합
|
||||
|
||||
#### 4-1. JournalEntry source_type 확장
|
||||
|
||||
```php
|
||||
// JournalEntry 모델
|
||||
const SOURCE_MANUAL = 'manual';
|
||||
const SOURCE_BANK_TRANSACTION = 'bank_transaction';
|
||||
const SOURCE_TAX_INVOICE = 'tax_invoice'; // 신규
|
||||
const SOURCE_CARD_TRANSACTION = 'card_transaction'; // 신규
|
||||
```
|
||||
|
||||
#### 4-2. 세금계산서 분개 통합
|
||||
|
||||
현재: `/api/v1/tax-invoices/{id}/journal-entries` → hometax_invoice_journals 저장
|
||||
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
|
||||
|
||||
- source_type = 'tax_invoice'
|
||||
- source_key = 'tax_invoice_{id}'
|
||||
- hometax_invoice_journals는 레거시 호환으로 유지 (향후 제거)
|
||||
|
||||
#### 4-3. 카드사용내역 분개 통합
|
||||
|
||||
현재: `/api/v1/card-transactions/{id}/journal-entries` → barobill_card_transaction_splits
|
||||
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
|
||||
|
||||
- source_type = 'card_transaction'
|
||||
- source_key = 'card_{id}'
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 대시보드 연동
|
||||
|
||||
#### 5-1. expense_accounts 동기화 공용화
|
||||
|
||||
현재 GeneralJournalEntryService에만 있는 syncExpenseAccounts를:
|
||||
- **JournalEntryService (공용)** 로 분리
|
||||
- 모든 분개 저장/수정/삭제 시 자동 호출
|
||||
- account_name에 '복리후생비' 또는 '접대비' 포함 → expense_accounts 동기화
|
||||
|
||||
#### 5-2. 검증
|
||||
|
||||
- 일반전표에서 복리후생비 분개 → 대시보드 반영 확인
|
||||
- 세금계산서에서 복리후생비 분개 → 대시보드 반영 확인
|
||||
- 카드내역에서 복리후생비 분개 → 대시보드 반영 확인
|
||||
|
||||
---
|
||||
|
||||
## 5. 작업 순서 및 의존성
|
||||
|
||||
```
|
||||
Phase 1: 백엔드 마스터 강화
|
||||
├── 1-1. 마이그레이션 + 모델
|
||||
├── 1-2. 서비스 + 컨트롤러
|
||||
└── 1-3. 시드 데이터
|
||||
↓
|
||||
Phase 2: 프론트 공용 컴포넌트
|
||||
├── 2-1. 공용 타입 + actions
|
||||
├── 2-2. AccountSubjectSettingModal
|
||||
└── 2-3. AccountSubjectSelect
|
||||
↓
|
||||
Phase 3: 7개 모듈 전환 ──────────── Phase 4: 분개 흐름 통합
|
||||
├── 3-1. 일반전표 ├── 4-1. source_type 확장
|
||||
├── 3-2. 세금계산서 ├── 4-2. 세금계산서 분개
|
||||
├── 3-3. 카드사용내역 └── 4-3. 카드 분개
|
||||
├── 3-4. 입금관리 ↓
|
||||
├── 3-5. 출금관리 Phase 5: 대시보드 연동
|
||||
├── 3-6. 미지급비용 ├── 5-1. 동기화 공용화
|
||||
└── 3-7. 매출관리 └── 5-2. 검증
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 리스크 및 주의사항
|
||||
|
||||
| 리스크 | 대응 |
|
||||
|--------|------|
|
||||
| 기존 데이터 마이그레이션 | 영문 키워드 → 숫자 코드 변환 마이그레이션 작성 |
|
||||
| 하드코딩 의존 코드 | 엑셀 다운로드 등에서 label 변환 로직 확인 |
|
||||
| API 하위호환 | 기존 엔드포인트 유지, 새 필드는 optional |
|
||||
| 시드 데이터 중복 | tenant별 기존 데이터 확인 후 없는 것만 추가 |
|
||||
172
claudedocs/[TASK-2026-03-03] daily-report-usd-section.md
Normal file
172
claudedocs/[TASK-2026-03-03] daily-report-usd-section.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 일일일보 — USD(외국환) 섹션 누락
|
||||
|
||||
**유형**: 프론트엔드 UI 누락
|
||||
**파일**: `src/components/accounting/DailyReport/index.tsx`
|
||||
**날짜**: 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
## 현상
|
||||
|
||||
일일일보 페이지에 KRW(원화) 계좌만 표시되고, USD(외국환) 계좌 섹션이 없음.
|
||||
summary에 `usd_totals`(이월/입금/출금/잔액)이 내려오고, daily-accounts에 `currency: 'USD'` 항목도 내려오지만 UI에서 렌더링하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 원인
|
||||
|
||||
모든 테이블에서 `currency === 'KRW'` 필터만 적용 중:
|
||||
|
||||
```tsx
|
||||
// line 391 — 계좌별 상세
|
||||
filteredDailyAccounts.filter(item => item.currency === 'KRW')
|
||||
|
||||
// line 448 — 입금 테이블
|
||||
filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0)
|
||||
|
||||
// line 497 — 출금 테이블
|
||||
filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 요구사항
|
||||
|
||||
기존 KRW 섹션과 동일한 구조로 USD 섹션 추가:
|
||||
|
||||
### 1. 일자별 상세 테이블에 USD 행 추가
|
||||
- 기존 KRW 계좌 목록 아래에 USD 계좌 목록 표시
|
||||
- 또는 KRW/USD 구분 소계 행으로 분리
|
||||
- 합계: `accountTotals.usd` 사용 (이미 계산 로직 있음, line 134-144)
|
||||
|
||||
### 2. 예금 입출금 내역에 USD 입금/출금 테이블 추가
|
||||
- 기존 KRW 입금/출금 아래에 USD 입금/출금 테이블 추가
|
||||
- 필터: `currency === 'USD' && item.income > 0` / `currency === 'USD' && item.expense > 0`
|
||||
- 금액 표시: USD 포맷 ($ 또는 달러 표기)
|
||||
|
||||
---
|
||||
|
||||
## 참고: 이미 준비된 데이터
|
||||
|
||||
### summary에서 내려오는 USD 데이터 (line 53-58)
|
||||
```typescript
|
||||
summary: {
|
||||
krwTotals: { carryover, income, expense, balance }, // ← 현재 사용 중
|
||||
usdTotals: { carryover, income, expense, balance }, // ← 미사용 (여기 추가)
|
||||
}
|
||||
```
|
||||
|
||||
### accountTotals 계산 로직 (line 134-144)
|
||||
```typescript
|
||||
// 이미 USD 합계 계산이 있음 — 사용만 하면 됨
|
||||
const usdAccounts = dailyAccounts.filter(item => item.currency === 'USD');
|
||||
const usdTotal = usdAccounts.reduce(
|
||||
(acc, item) => ({
|
||||
carryover: acc.carryover + item.carryover,
|
||||
income: acc.income + item.income,
|
||||
expense: acc.expense + item.expense,
|
||||
balance: acc.balance + item.balance,
|
||||
}),
|
||||
{ carryover: 0, income: 0, expense: 0, balance: 0 }
|
||||
);
|
||||
// accountTotals.usd 로 접근 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작업 범위
|
||||
|
||||
| 작업 | 설명 |
|
||||
|------|------|
|
||||
| 일자별 상세 테이블 | USD 계좌 행 추가 + USD 소계 행 |
|
||||
| 입금 테이블 | USD 입금 내역 추가 |
|
||||
| 출금 테이블 | USD 출금 내역 추가 |
|
||||
| 금액 포맷 | USD 표시 (달러 기호 또는 통화 표기) |
|
||||
|
||||
**수정 파일**: `src/components/accounting/DailyReport/index.tsx` (이 파일만)
|
||||
**새 코드 불필요**: API 데이터, 타입, 계산 로직 모두 이미 있음. 렌더링만 추가.
|
||||
|
||||
**상태**: ✅ 완료 (프론트엔드 렌더링 추가됨)
|
||||
|
||||
---
|
||||
|
||||
# CEO 대시보드 — 자금현황 데이터 정합성 이슈
|
||||
|
||||
**유형**: 백엔드 데이터 불일치
|
||||
**관련 API**: `GET /api/proxy/daily-report/summary`
|
||||
**관련 파일**: `sam-api/app/Services/DailyReportService.php`
|
||||
**날짜**: 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
## 현상
|
||||
|
||||
CEO 대시보드 자금현황 섹션의 **입금 합계**가 입금 관리 페이지(`/accounting/deposits`)의 실제 데이터와 불일치.
|
||||
|
||||
| 항목 | 대시보드 summary API | 입금 관리 페이지 API | 차이 |
|
||||
|------|---------------------|---------------------|------|
|
||||
| 3월 입금 합계 | **200,000원** | **50,000원** (1건) | **150,000원 차이** |
|
||||
| 3월 출금 합계 | 50,000원 | 50,000원 (1건) | 일치 |
|
||||
|
||||
---
|
||||
|
||||
## 자금현황 각 수치의 의미 (현재 구조)
|
||||
|
||||
```
|
||||
현금성 자산 합계 (cash_asset_total)
|
||||
= KRW 활성 계좌들의 누적 잔액 합계 (당월만이 아닌 전체 잔고)
|
||||
├── 전월이월(carryover): 49,872,638원 ← 3월 이전 누적 (입금총액 - 출금총액)
|
||||
├── 당월입금(income): 200,000원 ← 3월 1일~오늘 입금
|
||||
├── 당월출금(expense): 50,000원 ← 3월 1일~오늘 출금
|
||||
└── 잔액(balance): 50,022,638원 = 이월+입금-출금
|
||||
|
||||
외국환(USD) 합계 (foreign_currency_total) = USD 계좌 잔액 합계
|
||||
입금 합계 = krw_totals.income (당월 KRW 입금만)
|
||||
출금 합계 = krw_totals.expense (당월 KRW 출금만)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 원인 분석
|
||||
|
||||
### 대시보드 summary API 쿼리 (DailyReportService.php line 77-80)
|
||||
```php
|
||||
$income = Deposit::where('tenant_id', $tenantId)
|
||||
->where('bank_account_id', $account->id)
|
||||
->whereBetween('deposit_date', [$startOfMonth, $endOfDay])
|
||||
->sum('amount');
|
||||
```
|
||||
|
||||
### 입금 관리 페이지 API 쿼리
|
||||
- 별도 컨트롤러/서비스에서 조회
|
||||
- 동일한 `deposits` 테이블을 읽지만, 조회 조건이 다를 수 있음
|
||||
|
||||
### 불일치 가능 원인
|
||||
1. **soft delete 차이**: summary는 soft-deleted 레코드 포함, 목록 API는 제외
|
||||
2. **tenant_id 조건 차이**: 두 API의 tenant 필터링이 다를 수 있음
|
||||
3. **E2E 테스트 데이터**: 테스트가 DB에 직접 삽입한 레코드가 목록 API에서는 필터됨
|
||||
4. **status 필터**: 입금 관리 목록에 status 조건이 추가되어 일부 제외
|
||||
|
||||
---
|
||||
|
||||
## 확인 필요 사항 (백엔드)
|
||||
|
||||
### 1. deposits 테이블 직접 조회
|
||||
```sql
|
||||
SELECT id, deposit_date, amount, bank_account_id, deleted_at, status
|
||||
FROM deposits
|
||||
WHERE tenant_id = [현재테넌트]
|
||||
AND bank_account_id = 1
|
||||
AND deposit_date BETWEEN '2026-03-01' AND '2026-03-03'
|
||||
ORDER BY id;
|
||||
```
|
||||
→ 실제 레코드 수와 합계 확인 (soft delete, status 포함)
|
||||
|
||||
### 2. 두 API의 쿼리 조건 비교
|
||||
- `DailyReportService::dailyAccounts()` — Deposit 모델 조건
|
||||
- 입금 관리 컨트롤러/서비스 — Deposit 모델 조건
|
||||
- 차이점 확인 (withTrashed, status 등)
|
||||
|
||||
### 3. 해결 방향
|
||||
- 두 API가 동일한 데이터 소스를 보도록 통일
|
||||
- 또는 대시보드에서 기존 입금/출금 관리 API를 재사용하여 데이터 일관성 확보
|
||||
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-23)
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-03-09)
|
||||
|
||||
## 빠른 참조
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
| **[`[REF] technical-decisions.md`](./architecture/[REF]%20technical-decisions.md)** | 프로젝트 기술 결정 사항 (13개 항목) |
|
||||
| **[`[GUIDE] common-page-patterns.md`](./guides/[GUIDE]%20common-page-patterns.md)** | 공통 페이지 패턴 가이드 |
|
||||
|
||||
## 주간 구현내역
|
||||
|
||||
| 기간 | 문서 |
|
||||
|------|------|
|
||||
| 2026-03-02 ~ 03-08 | **[`[IMPL-2026-03-08] frontend-weekly-0302-0308.md`](./%5BIMPL-2026-03-08%5D%20frontend-weekly-0302-0308.md)** |
|
||||
| (백엔드 일별) | `backend/2026-03-02_구현내역.md` ~ `2026-03-08_구현내역.md` |
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조
|
||||
@@ -38,9 +45,11 @@ claudedocs/
|
||||
├── architecture/ # 아키텍처 & 시스템 & 기술 결정
|
||||
├── changes/ # 변경이력
|
||||
├── refactoring/ # 리팩토링 체크리스트
|
||||
├── outbound/ # 출하/배차관리
|
||||
├── vehicle/ # 차량관리
|
||||
├── material/ # 자재관리
|
||||
├── approval/ # 결재관리
|
||||
├── backend/ # 백엔드 일별 구현내역
|
||||
├── customer-center/ # 고객센터
|
||||
├── components/ # 컴포넌트 문서
|
||||
├── vercel/ # Vercel 배포
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 백엔드 API 수정 요청: 당월 예상 지출 상세 - 날짜 범위 필터링
|
||||
|
||||
## 엔드포인트
|
||||
`GET /api/v1/expected-expenses/dashboard-detail`
|
||||
|
||||
## 현재 상태
|
||||
- `transaction_type` 파라미터만 지원 (purchase, card, bill)
|
||||
- `start_date`, `end_date` 파라미터를 **무시**함
|
||||
- `items` 배열이 항상 **당월(현재 월)** 기준으로만 반환됨
|
||||
- `summary`도 당월 기준 고정 (total_amount, change_rate 등)
|
||||
- `monthly_trend`만 여러 월 데이터 포함 (최근 7개월)
|
||||
|
||||
## 요청 내용
|
||||
|
||||
### 1. 날짜 범위 필터 지원 추가
|
||||
```
|
||||
GET /api/v1/expected-expenses/dashboard-detail?transaction_type=purchase&start_date=2026-01-01&end_date=2026-01-31
|
||||
```
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 기본값 |
|
||||
|---------|------|------|--------|
|
||||
| `start_date` | string (yyyy-MM-dd) | 조회 시작일 | 당월 1일 |
|
||||
| `end_date` | string (yyyy-MM-dd) | 조회 종료일 | 당월 말일 |
|
||||
| `search` | string | 거래처/항목 검색 | (없음) |
|
||||
|
||||
### 2. 기대 동작
|
||||
- `items`: `start_date` ~ `end_date` 범위의 거래 내역만 반환
|
||||
- `summary.total_amount`: 해당 기간의 합계
|
||||
- `summary.change_rate`: 해당 기간 vs 직전 동일 기간 비교
|
||||
- `vendor_distribution`: 해당 기간 기준 분포
|
||||
- `footer_summary`: 해당 기간 기준 합계
|
||||
- `monthly_trend`: 변경 불필요 (기존처럼 최근 7개월 유지)
|
||||
|
||||
### 3. 검색 필터 (선택)
|
||||
- `search` 파라미터로 거래처명/항목명 부분 검색
|
||||
|
||||
## 검증 데이터
|
||||
현재 `monthly_trend` 기준 데이터가 있는 월:
|
||||
- 11월: 14,101,865원
|
||||
- 12월: 35,241,935원
|
||||
- 1월: 3,000,000원
|
||||
- 2월: 1,650,000원
|
||||
|
||||
`start_date=2026-01-01&end_date=2026-01-31` 조회 시:
|
||||
- `items`: 1월 거래 내역 (현재 빈 배열)
|
||||
- `summary.total_amount`: 3,000,000 (현재 0)
|
||||
|
||||
## 프론트엔드 준비 상태
|
||||
- 프록시: 쿼리 파라미터 정상 전달 확인
|
||||
- 훅: `fetchData(cardId, { startDate, endDate, search })` 지원
|
||||
- 모달: 조회 버튼 + 날짜 필터 UI 완료
|
||||
- 백엔드 수정만 되면 즉시 동작
|
||||
@@ -0,0 +1,821 @@
|
||||
# CEO Dashboard 백엔드 API 명세서
|
||||
|
||||
**작성일**: 2026-03-03
|
||||
**기획서**: SAM_ERP_Storyboard_D1.7_260227.pdf p33~60
|
||||
**프론트엔드 타입**: `src/lib/api/dashboard/types.ts`
|
||||
**대상**: 백엔드 팀 (Laravel sam-api)
|
||||
|
||||
---
|
||||
|
||||
## 공통 규칙
|
||||
|
||||
### 응답 형식
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "조회 성공",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 인증
|
||||
- 모든 API는 `Authorization: Bearer {access_token}` 필수
|
||||
- Next.js API route 프록시(`/api/proxy/...`) 경유
|
||||
|
||||
### 캐싱
|
||||
- `sam_stat` 테이블 5분 캐시 (기존 구현 유지)
|
||||
- 대시보드 API는 실시간성보다 성능 우선
|
||||
|
||||
### 날짜/기간 파라미터 규칙
|
||||
- 날짜: `YYYY-MM-DD` (예: `2026-03-03`)
|
||||
- 월: `YYYY-MM` (예: `2026-03`)
|
||||
- 분기: `year=2026&quarter=1`
|
||||
- 기본값: 파라미터 미지정 시 **당월/당분기** 기준
|
||||
|
||||
---
|
||||
|
||||
## 검수 중 발견된 누락 API
|
||||
|
||||
### N1. 오늘의 이슈 — 과거 이력 저장 및 조회
|
||||
**우선순위**: 상
|
||||
**페이지**: p34
|
||||
**현상**: `GET /api/v1/today-issues/summary?date=2026-02-17` 호출 시 항상 `{"items":[], "total_count":0}` 반환. 과거 이슈를 저장하는 구조가 없어서 이전 이슈 탭이 항상 빈 목록.
|
||||
|
||||
**요구사항**:
|
||||
1. **이슈 이력 테이블** 필요 (예: `dashboard_issue_history`)
|
||||
- 매일 자정(또는 배치) 시점에 당일 이슈 스냅샷 저장
|
||||
- 또는 이슈 발생 시점에 이력 테이블에 INSERT
|
||||
2. **기존 API 수정**: `GET /api/v1/today-issues/summary`
|
||||
- `date` 파라미터가 있을 때 해당 날짜의 이력 데이터 반환
|
||||
- `date` 파라미터가 없으면 기존대로 실시간 집계
|
||||
|
||||
**Response** (기존 `TodayIssueApiResponse`와 동일):
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "issue-20260302-001",
|
||||
"badge": "수주",
|
||||
"notification_type": "sales_order",
|
||||
"content": "대한건설 수주 3건 접수",
|
||||
"time": "14:30",
|
||||
"date": "2026-03-02",
|
||||
"path": "/ko/sales/order-management",
|
||||
"needs_approval": false
|
||||
}
|
||||
],
|
||||
"total_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 배치 저장 방식: `App\Console\Commands\SnapshotDailyIssues` (Schedule::daily)
|
||||
- 또는 이벤트 기반: 수주/채권/재고 변동 시 `dashboard_issue_history` INSERT
|
||||
|
||||
### N2. 자금현황 — 전일 대비 변동률 (daily_change)
|
||||
**우선순위**: 중
|
||||
**페이지**: p33
|
||||
**현상**: `GET /api/v1/daily-report/summary` 응답에 `daily_change` 필드가 없음. 프론트엔드에서 하드코딩 fallback 값(+5.2%, +2.1%, +12.0%, -8.0%)을 사용 중.
|
||||
|
||||
**요구사항**:
|
||||
1. **기존 API 수정**: `GET /api/v1/daily-report/summary`
|
||||
2. 응답에 `daily_change` 객체 추가
|
||||
3. 각 항목의 전일 대비 변동률(%) 계산 로직:
|
||||
- `cash_asset_change_rate`: (오늘 현금성자산 - 어제 현금성자산) / 어제 현금성자산 × 100
|
||||
- `foreign_currency_change_rate`: (오늘 외국환 - 어제 외국환) / 어제 외국환 × 100
|
||||
- `income_change_rate`: (오늘 입금 - 어제 입금) / 어제 입금 × 100
|
||||
- `expense_change_rate`: (오늘 지출 - 어제 지출) / 어제 지출 × 100
|
||||
4. 어제 데이터 없을 시 해당 필드 `null` (프론트에서 fallback 처리)
|
||||
|
||||
**Response** (기존 응답에 `daily_change` 추가):
|
||||
```json
|
||||
{
|
||||
"date": "2026-03-03",
|
||||
"day_of_week": "화",
|
||||
"cash_asset_total": 1250000000,
|
||||
"foreign_currency_total": 85000,
|
||||
"krw_totals": { "income": 45000000, "expense": 32000000, "balance": 1250000000 },
|
||||
"daily_change": {
|
||||
"cash_asset_change_rate": 5.2,
|
||||
"foreign_currency_change_rate": 2.1,
|
||||
"income_change_rate": 12.0,
|
||||
"expense_change_rate": -8.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `DailyReportService`에서 전일 데이터 조회 추가
|
||||
- `sam_stat` 캐시 테이블에 전일 스냅샷 있으면 활용
|
||||
- 프론트 타입: `DailyChangeRate` (`src/lib/api/dashboard/types.ts:23`)
|
||||
|
||||
### N3. 일일일보 — daily-accounts에 입출금관리 데이터 미반영
|
||||
**우선순위**: 상
|
||||
**페이지**: 일일일보 페이지 (`/ko/accounting/daily-report`)
|
||||
**현상**: 입금관리/출금관리에서 당일 거래를 등록하면 대시보드 자금현황(`daily-report/summary`)의 합계에는 즉시 반영되지만, 일일일보 페이지의 계좌별 상세 테이블(`daily-report/daily-accounts`)에는 표시되지 않음. (출금 테스트로 확인됨, 입금도 동일 구조로 미반영 추정)
|
||||
|
||||
**영향 범위**:
|
||||
| 데이터 | 관리 테이블 | summary (합계) | daily-accounts (상세) |
|
||||
|--------|-----------|:-:|:-:|
|
||||
| 입금 | `deposits` (`/api/v1/deposits`) | ✅ 반영 추정 | ❌ 미반영 추정 |
|
||||
| 출금 | `withdrawals` (`/api/v1/withdrawals`) | ✅ 반영 확인 | ❌ 미반영 확인 |
|
||||
| 외국환 (USD) | 별도 관리 페이지 미확인 | ✅ 반영 | ❓ 확인 필요 |
|
||||
|
||||
**원인 분석**:
|
||||
- `GET /api/v1/daily-report/summary` → `krw_totals`에 `deposits`/`withdrawals` 테이블 데이터 포함 ✅
|
||||
- `GET /api/v1/daily-report/daily-accounts` → `bank_accounts` 단위 집계만 반환, `deposits`/`withdrawals` 테이블 미포함 ❌
|
||||
|
||||
**데이터 흐름**:
|
||||
```
|
||||
입금관리 등록 → deposits 테이블 INSERT (bank_account_id 포함)
|
||||
출금관리 등록 → withdrawals 테이블 INSERT (bank_account_id 포함)
|
||||
├─ summary API → krw_totals.income/expense에 합산 → 대시보드 ✅
|
||||
└─ daily-accounts API → bank_accounts 기준만 조회 → 일일일보 상세 ❌
|
||||
```
|
||||
|
||||
**요구사항**:
|
||||
1. `GET /api/v1/daily-report/daily-accounts` 수정
|
||||
2. 각 계좌별로 `deposits` 테이블의 당일 income과 `withdrawals` 테이블의 당일 expense를 합산
|
||||
3. 또는 입금/출금 등록 시 해당 계좌의 거래 내역(`bank_account_transactions`)에도 자동 반영
|
||||
|
||||
**해결 방안 (택 1)**:
|
||||
- **방안 A** (daily-accounts 쿼리 수정): `bank_accounts` LEFT JOIN `deposits`/`withdrawals` WHERE date = 당일 → 계좌별 income/expense에 합산
|
||||
- **방안 B** (트랜잭션 연동): 입금/출금 등록 시 `bank_account_transactions`에도 INSERT → daily-accounts가 자연스럽게 포함
|
||||
|
||||
**Response** (기존 `DailyAccountItemApi[]`와 동일, 데이터만 보완):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "acc_1",
|
||||
"category": "우리은행 123-456",
|
||||
"match_status": "matched",
|
||||
"carryover": 50000000,
|
||||
"income": 1000000,
|
||||
"expense": 50000,
|
||||
"balance": 50950000,
|
||||
"currency": "KRW"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `DailyReportService`의 `getDailyAccounts()` 메서드 확인
|
||||
- `deposits` 테이블: `deposit.bank_account_id`로 해당 계좌 income 합산
|
||||
- `withdrawals` 테이블: `withdrawal.bank_account_id`로 해당 계좌 expense 합산
|
||||
- USD 계좌도 동일 패턴 적용 필요
|
||||
|
||||
### N4. 현황판 `purchases`(발주) — path 오류 + 데이터 정합성 이슈
|
||||
**우선순위**: 중
|
||||
**페이지**: p34 (현황판)
|
||||
|
||||
#### 이슈 A: path 하드코딩 오류
|
||||
**현상**: `purchases` 항목의 실제 데이터는 `purchases` 테이블(매입, 공통)에서 조회하면서, path는 건설 모듈 경로로 하드코딩되어 있음.
|
||||
|
||||
**문제 코드** (`StatusBoardService.php` — `getPurchaseStatus()`):
|
||||
```php
|
||||
$count = Purchase::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', 'draft')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'id' => 'purchases',
|
||||
'label' => '발주',
|
||||
'path' => '/construction/order/order-management', // ← 매입 데이터인데 건설 경로
|
||||
];
|
||||
```
|
||||
|
||||
- 데이터 출처: `purchases` 테이블 (모든 테넌트 공통 매입 테이블)
|
||||
- path: `/construction/order/order-management` (건설 전용 페이지)
|
||||
- **데이터와 path가 불일치** — 매입 draft 건수를 보여주면서 건설 발주 페이지로 링크
|
||||
|
||||
**현재 프론트 임시 대응**: `status-issue.ts`에서 `/accounting/purchase`(매입관리)로 오버라이드 중
|
||||
|
||||
**요구사항**:
|
||||
1. path를 `/accounting/purchase`로 변경 (데이터 출처와 일치시키기)
|
||||
2. 또는 테넌트 업종에 따라 path 동적 분기 (건설: `/construction/order/order-management`, 기타: `/accounting/purchase`)
|
||||
3. 라벨도 재검토: "발주"가 맞는지, "매입(임시저장)"이 더 정확한지
|
||||
|
||||
#### 이슈 B: 데이터 정합성 의심
|
||||
**현상**: StatusBoard API에서 `purchases` count=**9건** 반환, 하지만 매입관리 페이지(`/accounting/purchase`)에서 전체 조회 시 **1건**만 표시.
|
||||
|
||||
**확인 사항** (DB 직접 확인 필요):
|
||||
```sql
|
||||
-- 현재 테넌트의 purchases 테이블 전체 건수
|
||||
SELECT COUNT(*), status FROM purchases WHERE tenant_id = {현재 테넌트 ID} GROUP BY status;
|
||||
|
||||
-- draft 상태 건수 (StatusBoard가 조회하는 조건)
|
||||
SELECT COUNT(*) FROM purchases WHERE tenant_id = {현재 테넌트 ID} AND status = 'draft';
|
||||
```
|
||||
|
||||
**가능한 원인**:
|
||||
1. StatusBoard와 매입관리 페이지가 다른 tenant_id 스코프로 조회
|
||||
2. DummyDataSeeder가 다른 tenant_id로 데이터 생성
|
||||
3. 매입관리 API에 추가 필터 조건이 있어서 draft 건이 제외됨
|
||||
4. StatusBoard가 실제와 다른 데이터를 집계
|
||||
|
||||
**기대 결과**: StatusBoard 9건 클릭 → 매입관리 페이지에서 9건 확인 가능해야 함
|
||||
|
||||
---
|
||||
|
||||
## 신규 API (10개)
|
||||
|
||||
### 1. 매출 현황 Summary
|
||||
**우선순위**: 중
|
||||
**페이지**: p39
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/sales/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| year | int | N | 조회 연도 (기본: 당해) |
|
||||
| month | int | N | 조회 월 (기본: 당월) |
|
||||
|
||||
**Response** (`SalesStatusApiResponse`):
|
||||
```json
|
||||
{
|
||||
"cumulative_sales": 312300000,
|
||||
"achievement_rate": 94.5,
|
||||
"yoy_change": 12.5,
|
||||
"monthly_sales": 312300000,
|
||||
"monthly_trend": [
|
||||
{ "month": "2026-08", "label": "8월", "amount": 250000000 },
|
||||
{ "month": "2026-09", "label": "9월", "amount": 280000000 }
|
||||
],
|
||||
"client_sales": [
|
||||
{ "name": "대한건설", "amount": 95000000 },
|
||||
{ "name": "삼성테크", "amount": 78000000 }
|
||||
],
|
||||
"daily_items": [
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"client": "대한건설",
|
||||
"item": "스크린 외",
|
||||
"amount": 25000000,
|
||||
"status": "deposited"
|
||||
}
|
||||
],
|
||||
"daily_total": 312300000
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 매출: `sales_orders` 합계 (confirmed 상태)
|
||||
- 달성률: 매출 목표 대비 (`sales_targets` 테이블)
|
||||
- YoY: 전년 동월 대비 변화율
|
||||
- 거래처별: GROUP BY vendor_id → TOP 5
|
||||
- status 코드: `deposited` (입금완료), `unpaid` (미입금), `partial` (부분입금)
|
||||
|
||||
---
|
||||
|
||||
### 2. 매입 현황 Summary
|
||||
**우선순위**: 중
|
||||
**페이지**: p40
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/purchases/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| year | int | N | 조회 연도 (기본: 당해) |
|
||||
| month | int | N | 조회 월 (기본: 당월) |
|
||||
|
||||
**Response** (`PurchaseStatusApiResponse`):
|
||||
```json
|
||||
{
|
||||
"cumulative_purchase": 312300000,
|
||||
"unpaid_amount": 312300000,
|
||||
"yoy_change": -12.5,
|
||||
"monthly_trend": [
|
||||
{ "month": "2026-08", "label": "8월", "amount": 180000000 }
|
||||
],
|
||||
"material_ratio": [
|
||||
{ "name": "원자재", "value": 55, "percentage": 55, "color": "#3b82f6" },
|
||||
{ "name": "부자재", "value": 35, "percentage": 35, "color": "#10b981" },
|
||||
{ "name": "소모품", "value": 10, "percentage": 10, "color": "#f59e0b" }
|
||||
],
|
||||
"daily_items": [
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"supplier": "한국철강",
|
||||
"item": "철판 외",
|
||||
"amount": 45000000,
|
||||
"status": "paid"
|
||||
}
|
||||
],
|
||||
"daily_total": 312300000
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 매입: `purchase_orders` 합계
|
||||
- 미결제: 결제 미완료 건 합계
|
||||
- 원자재/부자재/소모품: `item_categories` 기준 분류
|
||||
- status 코드: `paid` (결제완료), `unpaid` (미결제), `partial` (부분결제)
|
||||
|
||||
---
|
||||
|
||||
### 3. 생산 현황 Summary
|
||||
**우선순위**: 상
|
||||
**페이지**: p41
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/production/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) |
|
||||
|
||||
**Response** (`DailyProductionApiResponse`):
|
||||
```json
|
||||
{
|
||||
"date": "2026-02-23",
|
||||
"day_of_week": "월요일",
|
||||
"processes": [
|
||||
{
|
||||
"process_name": "스크린",
|
||||
"total_work": 10,
|
||||
"todo": 3,
|
||||
"in_progress": 4,
|
||||
"completed": 3,
|
||||
"urgent": 2,
|
||||
"sub_line": 1,
|
||||
"regular": 5,
|
||||
"worker_count": 8,
|
||||
"work_items": [
|
||||
{
|
||||
"id": "wo_1",
|
||||
"order_no": "SO-2026-001",
|
||||
"client": "대한건설",
|
||||
"product": "스크린 A형",
|
||||
"quantity": 50,
|
||||
"status": "in_progress"
|
||||
}
|
||||
],
|
||||
"workers": [
|
||||
{
|
||||
"name": "김철수",
|
||||
"assigned": 5,
|
||||
"completed": 3,
|
||||
"rate": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"shipment": {
|
||||
"expected_amount": 150000000,
|
||||
"expected_count": 12,
|
||||
"actual_amount": 120000000,
|
||||
"actual_count": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 공정: `work_processes` 테이블 (스크린, 슬랫, 절곡 등)
|
||||
- 작업: `work_orders` JOIN `work_process_id`
|
||||
- status: `pending` → todo, `in_progress`, `completed`
|
||||
- urgent: 납기 3일 이내
|
||||
- 출고: `shipments` 테이블 (당일 예상 vs 실적)
|
||||
|
||||
---
|
||||
|
||||
### 4. 출고 현황 (생산 현황에 포함)
|
||||
**우선순위**: 하
|
||||
**페이지**: p41
|
||||
|
||||
생산 현황 API의 `shipment` 필드로 포함됨. 별도 API 불필요.
|
||||
|
||||
---
|
||||
|
||||
### 5. 미출고 내역
|
||||
**우선순위**: 하
|
||||
**페이지**: p42
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/unshipped/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| days | int | N | 납기 N일 이내 (기본: 30) |
|
||||
|
||||
**Response** (`UnshippedApiResponse`):
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "us_1",
|
||||
"port_no": "P-2026-001",
|
||||
"site_name": "강남 현장",
|
||||
"order_client": "대한건설",
|
||||
"due_date": "2026-02-25",
|
||||
"days_left": 2
|
||||
}
|
||||
],
|
||||
"total_count": 7
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `shipment_items` WHERE shipped_at IS NULL AND due_date >= NOW()
|
||||
- days_left: DATEDIFF(due_date, NOW())
|
||||
- ORDER BY due_date ASC (납기 임박 순)
|
||||
|
||||
---
|
||||
|
||||
### 6. 시공 현황
|
||||
**우선순위**: 중
|
||||
**페이지**: p42
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/construction/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| month | int | N | 조회 월 (기본: 당월) |
|
||||
|
||||
**Response** (`ConstructionApiResponse`):
|
||||
```json
|
||||
{
|
||||
"this_month": 15,
|
||||
"completed": 5,
|
||||
"items": [
|
||||
{
|
||||
"id": "cs_1",
|
||||
"site_name": "강남 현장",
|
||||
"client": "대한건설",
|
||||
"start_date": "2026-02-01",
|
||||
"end_date": "2026-02-28",
|
||||
"progress": 85,
|
||||
"status": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `constructions` 테이블
|
||||
- status: `in_progress`, `scheduled`, `completed`
|
||||
- completed: 최근 7일 이내 완료 건
|
||||
|
||||
---
|
||||
|
||||
### 7. 근태 현황
|
||||
**우선순위**: 중
|
||||
**페이지**: p43
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/attendance/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) |
|
||||
|
||||
**Response** (`DailyAttendanceApiResponse`):
|
||||
```json
|
||||
{
|
||||
"present": 42,
|
||||
"on_leave": 3,
|
||||
"late": 1,
|
||||
"absent": 0,
|
||||
"employees": [
|
||||
{
|
||||
"id": "emp_1",
|
||||
"department": "생산부",
|
||||
"position": "과장",
|
||||
"name": "김철수",
|
||||
"status": "present"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `attendances` WHERE date = :date
|
||||
- status: `present`, `on_leave`, `late`, `absent`
|
||||
- employees: 이상 상태(late, absent, on_leave) 위주 표시
|
||||
|
||||
---
|
||||
|
||||
### 8. 일별 매출 내역
|
||||
**우선순위**: 하
|
||||
**페이지**: p47 (설정 팝업에서 별도 ON/OFF)
|
||||
|
||||
매출 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시:
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/sales/daily
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| start_date | string | N | 시작일 (기본: 당월 1일) |
|
||||
| end_date | string | N | 종료일 (기본: 오늘) |
|
||||
| page | int | N | 페이지 (기본: 1) |
|
||||
| per_page | int | N | 건수 (기본: 20) |
|
||||
|
||||
---
|
||||
|
||||
### 9. 일별 매입 내역
|
||||
**우선순위**: 하
|
||||
|
||||
매입 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시:
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/purchases/daily
|
||||
```
|
||||
|
||||
(매출 일별과 동일 구조)
|
||||
|
||||
---
|
||||
|
||||
### 10. 접대비 상세
|
||||
**우선순위**: 상
|
||||
**페이지**: p53-54
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/entertainment/detail
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| year | int | N | 연도 |
|
||||
| quarter | int | N | 분기 (1-4) |
|
||||
| limit_type | string | N | annual/quarterly |
|
||||
| company_type | string | N | large/medium/small |
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_used": 10000000,
|
||||
"annual_limit": 40120000,
|
||||
"remaining": 30120000,
|
||||
"usage_rate": 24.9
|
||||
},
|
||||
"limit_calculation": {
|
||||
"base_limit": 36000000,
|
||||
"revenue_additional": 4120000,
|
||||
"total_limit": 40120000,
|
||||
"revenue": 2060000000,
|
||||
"company_type": "medium"
|
||||
},
|
||||
"quarterly_status": [
|
||||
{
|
||||
"quarter": 1,
|
||||
"label": "1분기",
|
||||
"limit": 10030000,
|
||||
"used": 3500000,
|
||||
"remaining": 6530000,
|
||||
"exceeded": 0
|
||||
}
|
||||
],
|
||||
"transactions": [
|
||||
{
|
||||
"id": 1,
|
||||
"date": "2026-01-15",
|
||||
"user_name": "홍길동",
|
||||
"merchant_name": "강남식당",
|
||||
"amount": 350000,
|
||||
"counterpart": "대한건설",
|
||||
"receipt_type": "법인카드",
|
||||
"risk_flags": ["high_amount"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정 API (6개)
|
||||
|
||||
### 1. 가지급금 Summary (수정)
|
||||
**현재**: 카드/가지급금/법인세/종합세
|
||||
**변경**: 카드/경조사/상품권/접대비/총합계 (5카드)
|
||||
|
||||
```
|
||||
GET /api/proxy/card-transactions/summary
|
||||
```
|
||||
|
||||
**Response 변경**:
|
||||
```json
|
||||
{
|
||||
"cards": [
|
||||
{ "id": "cm1", "label": "카드", "amount": 3123000, "sub_label": "미정리 5건", "count": 5 },
|
||||
{ "id": "cm2", "label": "경조사", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
|
||||
{ "id": "cm3", "label": "상품권", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
|
||||
{ "id": "cm4", "label": "접대비", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
|
||||
{ "id": "cm_total", "label": "총 가지급금 합계", "amount": 350000000 }
|
||||
],
|
||||
"check_points": [
|
||||
{
|
||||
"id": "cm-cp1",
|
||||
"type": "warning",
|
||||
"message": "법인카드 사용 총 850만원이 가지급금으로 전환되었습니다.",
|
||||
"highlights": [{ "text": "850만원", "color": "red" }]
|
||||
}
|
||||
],
|
||||
"warning_banner": "가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의"
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 분류: `card_transactions.category` 기준 (card/congratulation/gift_card/entertainment)
|
||||
- 미정리/미증빙: `evidence_status = 'pending'` COUNT
|
||||
|
||||
---
|
||||
|
||||
### 2. 접대비 Summary (수정)
|
||||
**현재**: 매출/한도/잔여한도/사용금액
|
||||
**변경**: 주말심야/기피업종/고액결제/증빙미비 (리스크 4종)
|
||||
|
||||
```
|
||||
GET /api/proxy/entertainment/summary
|
||||
```
|
||||
|
||||
**Response 변경**:
|
||||
```json
|
||||
{
|
||||
"cards": [
|
||||
{ "id": "et1", "label": "주말/심야", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "et2", "label": "기피업종 (유흥, 귀금속 등)", "amount": 3123000, "sub_label": "불인정 5건", "count": 5 },
|
||||
{ "id": "et3", "label": "고액 결제", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "et4", "label": "증빙 미비", "amount": 3123000, "sub_label": "5건", "count": 5 }
|
||||
],
|
||||
"check_points": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**리스크 감지 로직** (p60 참조):
|
||||
- 주말/심야: 토~일, 22:00~06:00 거래
|
||||
- 기피업종: MCC 코드 기반 (유흥업소 7273, 귀금속 5944, 골프장 7941 등)
|
||||
- 고액 결제: 설정 금액(기본 50만원) 초과
|
||||
- 증빙 미비: 적격증빙(세금계산서/카드매출전표) 없는 건
|
||||
|
||||
---
|
||||
|
||||
### 3. 복리후생비 Summary (수정)
|
||||
**현재**: 한도/잔여한도/사용금액
|
||||
**변경**: 비과세한도초과/사적사용의심/특정인편중/항목별한도초과 (리스크 4종)
|
||||
|
||||
```
|
||||
GET /api/proxy/welfare/summary
|
||||
```
|
||||
|
||||
**Response 변경**:
|
||||
```json
|
||||
{
|
||||
"cards": [
|
||||
{ "id": "wf1", "label": "비과세 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "wf2", "label": "사적 사용 의심", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "wf3", "label": "특정인 편중", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "wf4", "label": "항목별 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 }
|
||||
],
|
||||
"check_points": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**리스크 감지 로직**:
|
||||
- 비과세 한도 초과: 항목별 비과세 기준 초과 (식대 20만원, 교통비 10만원 등)
|
||||
- 사적 사용 의심: 주말/야간 + 비업무 업종 조합
|
||||
- 특정인 편중: 직원별 사용액 편차 > 평균의 200%
|
||||
- 항목별 한도 초과: 설정 금액 초과
|
||||
|
||||
---
|
||||
|
||||
### 4. 가지급금 Detail (수정)
|
||||
|
||||
기존 `LoanDashboardApiResponse`에 AI분류 컬럼 추가.
|
||||
|
||||
```
|
||||
GET /api/v1/loans/dashboard
|
||||
```
|
||||
|
||||
**Response 추가 필드**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"...기존 필드...",
|
||||
"ai_category": "카드",
|
||||
"evidence_status": "미증빙"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 복리후생비 Detail (수정)
|
||||
|
||||
기존 `WelfareDetailApiResponse`에 계산방식 파라미터 추가.
|
||||
|
||||
```
|
||||
GET /api/proxy/welfare/detail?calculation_type=fixed&fixed_amount_per_month=200000
|
||||
```
|
||||
|
||||
(기존 구현 유지, 계산 파라미터만 반영 확인)
|
||||
|
||||
---
|
||||
|
||||
### 6. 부가세 Detail (수정)
|
||||
|
||||
기존 `VatApiResponse`에 신고기간 파라미터 반영.
|
||||
|
||||
```
|
||||
GET /api/proxy/vat/summary?period_type=quarter&year=2026&period=1
|
||||
```
|
||||
|
||||
(기존 구현 유지, 기간별 필터링 확인)
|
||||
|
||||
---
|
||||
|
||||
## 리스크 감지 로직 참고 (p58-60)
|
||||
|
||||
### MCC 코드 기피업종
|
||||
| MCC | 업종 | 분류 |
|
||||
|-----|------|------|
|
||||
| 7273 | 유흥업소 | 기피업종 |
|
||||
| 5944 | 귀금속 | 기피업종 |
|
||||
| 7941 | 골프장 | 기피업종 |
|
||||
| 5813 | 주점 | 기피업종 |
|
||||
| 7011 | 호텔/리조트 | 주의업종 |
|
||||
|
||||
### 리스크 판별 규칙
|
||||
```
|
||||
규칙1: 시간대 이상 → 22:00~06:00 또는 토~일
|
||||
규칙2: 업종 이상 → MCC 기피업종 해당
|
||||
규칙3: 금액 이상 → 설정 금액 초과 (기본 50만원)
|
||||
규칙4: 빈도 이상 → 월 10회 이상 동일 업종
|
||||
규칙5: 증빙 미비 → 적격증빙 없음
|
||||
|
||||
리스크 등급:
|
||||
- 2개 이상 해당 → 🔴 고위험
|
||||
- 1개 해당 → 🟡 주의
|
||||
- 0개 → 🟢 정상
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 계산 공식 참고
|
||||
|
||||
### 가지급금 인정이자 (p58)
|
||||
```
|
||||
인정이자 = 가지급금잔액 × (4.6% / 365) × 경과일수
|
||||
법인세 추가 = 인정이자 × 19%
|
||||
대표자 소득세 = 인정이자 × 35%
|
||||
```
|
||||
|
||||
### 접대비 손금한도 (p59)
|
||||
```
|
||||
기본한도:
|
||||
일반법인: 1,200만원/년
|
||||
중소기업: 3,600만원/년
|
||||
|
||||
수입금액별 추가:
|
||||
100억 이하: 수입금액 × 0.2%
|
||||
100~500억: 2,000만원 + (수입금액-100억) × 0.1%
|
||||
500억 초과: 6,000만원 + (수입금액-500억) × 0.03%
|
||||
```
|
||||
|
||||
### 복리후생비 (p60)
|
||||
```
|
||||
방식1 (정액): 직원수 × 월정액 × 12
|
||||
방식2 (비율): 연봉총액 × 비율%
|
||||
|
||||
비과세 한도:
|
||||
식대: 20만원/월
|
||||
교통비: 10만원/월
|
||||
경조사: 5만원/건
|
||||
건강검진: 연간 총액/12 환산
|
||||
교육훈련: 8만원/월
|
||||
복지포인트: 10만원/월
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 정리
|
||||
|
||||
| 우선순위 | API | 이유 |
|
||||
|---------|-----|------|
|
||||
| 🔴 상 | 접대비 summary 수정, 복리후생비 summary 수정 | D1.7 카드 구조 변경 |
|
||||
| 🔴 상 | 가지급금 summary 수정 | D1.7 카드 구조 변경 |
|
||||
| 🔴 상 | 접대비 detail 신규 | 모달 확장 |
|
||||
| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 |
|
||||
| 🟡 중 | 생산 현황 | 복잡한 공정 집계 |
|
||||
| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 |
|
||||
@@ -0,0 +1,59 @@
|
||||
# 전자결재 결재함 확장 및 연결문서 기능
|
||||
|
||||
> **작업일**: 2026-03-01 ~ 03-07
|
||||
> **상태**: ✅ 완료
|
||||
> **커밋**: 181352d7, 72cf5d86
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
결재함(ApprovalBox) API 연동, 연결문서(LinkedDocumentContent) 렌더링,
|
||||
모바일 반응형 레이아웃 개선.
|
||||
|
||||
---
|
||||
|
||||
## 1. 결재함 API 연동
|
||||
|
||||
- [x] 결재함 목록: `GET /api/v1/approvals/inbox`
|
||||
- [x] 결재함 통계: `GET /api/v1/approvals/inbox/summary`
|
||||
- [x] 승인 처리: `POST /api/v1/approvals/{id}/approve`
|
||||
- [x] 반려 처리: `POST /api/v1/approvals/{id}/reject`
|
||||
- [x] 문서 상태 매핑: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED
|
||||
- [x] 결재함 상태 헬퍼 함수 추가
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/approval/ApprovalBox/actions.ts` (+123/-7)
|
||||
- `src/components/approval/ApprovalBox/index.tsx` (+47/-1)
|
||||
- `src/components/approval/ApprovalBox/types.ts` (+9/-1)
|
||||
|
||||
---
|
||||
|
||||
## 2. 연결문서 기능 (LinkedDocumentContent) — 신규
|
||||
|
||||
검사성적서, 작업일지 등 문서관리 시스템의 문서를 결재 문서에 연결하여 렌더링.
|
||||
|
||||
- [x] `LinkedDocumentContent` 컴포넌트 신규 생성
|
||||
- [x] `DocumentHeader` 컴포넌트 활용 (일관된 스타일)
|
||||
- [x] 결재라인 / 상태배지 / 문서 메타정보 표시
|
||||
- [x] `DocumentDetailModalV2`에 연결문서 렌더링 통합
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규, +133)
|
||||
- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx`
|
||||
- `src/components/approval/DocumentDetail/types.ts` (+27/-1)
|
||||
|
||||
---
|
||||
|
||||
## 3. 모바일 반응형 개선
|
||||
|
||||
- [x] `AuthenticatedLayout`: 사이드바/메인 콘텐츠 모바일 대응
|
||||
- [x] `HeaderFavoritesBar`: 전면 재설계 (+315/-127)
|
||||
- [x] `Sidebar`: 반응형 숨김/표시
|
||||
- [x] `SearchableSelectionModal`: HTML 유효성 에러 수정
|
||||
|
||||
### 주요 파일
|
||||
- `src/layouts/AuthenticatedLayout.tsx` (+12/-1)
|
||||
- `src/components/layout/HeaderFavoritesBar.tsx` (+315/-127)
|
||||
- `src/components/layout/Sidebar.tsx` (+8/-1)
|
||||
- `src/components/organisms/SearchableSelectionModal.tsx` (+79/-2)
|
||||
@@ -0,0 +1,176 @@
|
||||
# CEO Dashboard 분석 (기획서 D1.7 기준)
|
||||
|
||||
**기획서**: `SAM_ERP_Storyboard_D1.7_260227.pdf` p33~60
|
||||
**분석일**: 2026-02-27
|
||||
**상태**: 기획서 분석 완료, 구현 대기
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 구성
|
||||
|
||||
| 구분 | 페이지 | 수량 |
|
||||
|------|--------|------|
|
||||
| 메인 대시보드 섹션 | p33~43 | 20개 |
|
||||
| 상세 모달 | p44~57 | 10개 |
|
||||
| 참고 자료 (계산공식) | p58~60 | 3페이지 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 섹션별 현황 (20개)
|
||||
|
||||
### API 연동 완료 (11개)
|
||||
|
||||
| # | 섹션 | 페이지 | hook | API endpoint |
|
||||
|---|------|--------|------|-------------|
|
||||
| 1 | 오늘의 이슈 | p33 | useTodayIssue | today-issues/summary |
|
||||
| 2 | 자금 현황 | p33-34 | useCEODashboard | daily-report/summary |
|
||||
| 3 | 현황판 | p34 | useStatusBoard | status-board/summary |
|
||||
| 4 | 당월 예상 지출 | p34-35 | useMonthlyExpense | expected-expenses/summary |
|
||||
| 5 | 가지급금 현황 | p35 | useCardManagement | card-transactions/summary + 2개 |
|
||||
| 6 | 접대비 현황 | p35-36 | useEntertainment | entertainment/summary |
|
||||
| 7 | 복리후생비 현황 | p36 | useWelfare | welfare/summary |
|
||||
| 8 | 미수금 현황 | p36 | useReceivable | receivables/summary |
|
||||
| 9 | 채권추심 현황 | p37 | useDebtCollection | bad-debts/summary |
|
||||
| 10 | 부가세 현황 | p37-38 | useVat | vat/summary |
|
||||
| 11 | 캘린더 | p38 | useCalendar | calendar/schedules |
|
||||
|
||||
### Mock 데이터만 (9개) - API 신규 필요
|
||||
|
||||
| # | 섹션 | 페이지 | 필요 데이터 |
|
||||
|---|------|--------|-----------|
|
||||
| 12 | 매출 현황 | p39 | 누적매출, 달성률, YoY, 당월매출 + 차트2 + 테이블 |
|
||||
| 13 | 일별 매출 내역 | p47(설정) | 매출일, 거래처, 매출금액 (🆕 신규 섹션) |
|
||||
| 14 | 매입 현황 | p40 | 누적매입, 미결제, YoY + 차트2 + 테이블 |
|
||||
| 15 | 일별 매입 내역 | p47(설정) | 매입일, 거래처, 매입금액 (🆕 신규 섹션) |
|
||||
| 16 | 생산 현황 | p41 | 공정별(스크린/슬랫/절곡) 집계 + 작업자현황 |
|
||||
| 17 | 출고 현황 | p41 | 예상출고 7일/30일 금액+건수 |
|
||||
| 18 | 미출고 내역 | p42 | 로트번호, 현장명, 수주처, 잔량, 납기일 |
|
||||
| 19 | 시공 현황 | p42 | 진행/완료(7일이내) + 현장카드 |
|
||||
| 20 | 근태 현황 | p43 | 출근/휴가/지각/결근 + 직원테이블 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔴 D1.7 핵심 변경사항
|
||||
|
||||
### 카드 구조 변경 (한도관리형 → 리스크감지형)
|
||||
|
||||
| 섹션 | 기존 구현 | D1.7 기획서 |
|
||||
|------|---------|-----------|
|
||||
| **가지급금** | 카드, 가지급금, 법인세예상, 종합세예상 | 카드, 경조사, 상품권, 접대비, 총합계 (5카드) |
|
||||
| **접대비** | 매출, 분기한도, 잔여한도, 사용금액 | **주말/심야, 기피업종, 고액결제, 증빙미비** |
|
||||
| **복리후생비** | 당해한도, 분기한도, 잔여한도, 사용금액 | **비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과** |
|
||||
|
||||
### 신규 섹션 (2개)
|
||||
- 일별 매출 내역: 항목 설정(p47)에서 별도 ON/OFF
|
||||
- 일별 매입 내역: 항목 설정(p47)에서 별도 ON/OFF
|
||||
|
||||
### 설정 팝업 확장 (p45-47)
|
||||
- 접대비: 한도관리(연간/반기/분기/월), 기업구분(일반법인/중소기업), 고액결제기준금액
|
||||
- 복리후생비: 한도관리, 계산방식(직원당정액 or 연봉총액×비율), 조건부입력필드, 1회결제기준금액
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세 모달 (10개)
|
||||
|
||||
| # | 모달 | 페이지 | 프론트 config | API 상태 |
|
||||
|---|------|--------|-------------|---------|
|
||||
| 1 | 일정 상세 | p44 | ✅ ScheduleDetailModal | ✅ 연동 |
|
||||
| 2 | 항목 설정 | p45-47 | ✅ DashboardSettingsDialog | localStorage |
|
||||
| 3 | 당월 매입 상세 | p48 | ✅ me1 config | ⚠️ 부분연동 |
|
||||
| 4 | 당월 카드 상세 | p49 | ✅ me2 config | ⚠️ 부분연동 |
|
||||
| 5 | 당월 발행어음 상세 | p50 | ✅ me3 config | ⚠️ 부분연동 |
|
||||
| 6 | 당월 지출 예상 상세 | p51 | ✅ me4 config | ⚠️ 부분연동 |
|
||||
| 7 | 가지급금 상세 | p52 | ✅ cm2 config | ⚠️ 구조변경 필요 |
|
||||
| 8 | 접대비 상세 | p53-54 | ✅ et config | ⚠️ 대폭확장 |
|
||||
| 9 | 복리후생비 상세 | p55-56 | ✅ wf config | ⚠️ 대폭확장 |
|
||||
| 10 | 예상 납부세액 상세 | p57 | ✅ vat config | ⚠️ 확장필요 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 필요 API 작업 (16개)
|
||||
|
||||
### 백엔드 API 수정 (6개)
|
||||
|
||||
| # | API | 변경 내용 |
|
||||
|---|-----|---------|
|
||||
| 1 | 가지급금 summary | 카드/경조사/상품권/접대비 분류 집계 |
|
||||
| 2 | 접대비 summary | 리스크 4종 (주말심야/기피업종/고액/증빙미비) - MCC코드 판별 |
|
||||
| 3 | 복리후생비 summary | 리스크 4종 (비과세초과/사적사용/편중/한도초과) |
|
||||
| 4 | 가지급금 detail | 분류별 상세 + AI분류 컬럼 |
|
||||
| 5 | 복리후생비 detail | 계산방식별 + 분기별현황 |
|
||||
| 6 | 부가세 detail | 신고기간별 + 부가세요약 + 미발행/미수취 |
|
||||
|
||||
### 백엔드 API 신규 (10개)
|
||||
|
||||
| # | API | 용도 | 난이도 |
|
||||
|---|-----|------|--------|
|
||||
| 1 | 접대비 detail | 한도계산 + 분기별현황 + 내역테이블 | 상 |
|
||||
| 2 | 매출 현황 summary | 누적/달성률/YoY/당월 + 차트 | 중 |
|
||||
| 3 | 일별 매출 내역 | 매출일, 거래처, 매출금액 | 하 |
|
||||
| 4 | 매입 현황 summary | 누적/미결제/YoY + 차트 | 중 |
|
||||
| 5 | 일별 매입 내역 | 매입일, 거래처, 매입금액 | 하 |
|
||||
| 6 | 생산 현황 | 공정별 집계 + 작업자실적 | 상 |
|
||||
| 7 | 출고 현황 | 7일/30일 예상출고 | 하 |
|
||||
| 8 | 미출고 내역 | 납기기준 미출고 조회 | 하 |
|
||||
| 9 | 시공 현황 | 진행/완료(7일이내) + 카드 | 중 |
|
||||
| 10 | 근태 현황 | 출근/휴가/지각/결근 집계 | 중 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 작업 (8개)
|
||||
|
||||
| # | 작업 | 대상 |
|
||||
|---|------|------|
|
||||
| 1 | 가지급금 카드 구조 변경 | CardManagementSection |
|
||||
| 2 | 접대비 카드 → 리스크형 | EntertainmentSection |
|
||||
| 3 | 복리후생비 카드 → 리스크형 | WelfareSection |
|
||||
| 4 | 일별 매출 내역 섹션 신규 | 새 컴포넌트 |
|
||||
| 5 | 일별 매입 내역 섹션 신규 | 새 컴포넌트 |
|
||||
| 6 | 항목 설정 팝업 업데이트 | DashboardSettingsDialog |
|
||||
| 7 | 모달 config API 연동 | 각 modalConfigs |
|
||||
| 8 | Mock 섹션 API 연동 | 매출~근태 hook 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 데이터 아키텍처
|
||||
|
||||
대시보드 전용 테이블 없음. 모든 데이터는 각 도메인 페이지 입력 데이터의 실시간 집계.
|
||||
|
||||
### 자금 현황 데이터 조합
|
||||
| 카드 | 출처 |
|
||||
|------|------|
|
||||
| 일일일보 | bank_accounts 잔액 합계 |
|
||||
| 미수금 잔액 | sales 합계 - deposits 합계 |
|
||||
| 미지급금 잔액 | purchases 합계 - payments 합계 |
|
||||
| 당월 예상 지출 | 매입예정 + 카드결제 + 어음만기 합산 |
|
||||
|
||||
### 리스크 감지 로직 (접대비/복리후생비)
|
||||
- MCC 코드 기반 업종 판별 (p60: 유흥업소, 귀금속, 골프장 등)
|
||||
- 체크 규칙: 시간대이상(22~06시), 업종이상, 금액이상(50만원), 빈도이상(월10회)
|
||||
- 사적사용 의심: 토요일 23시 + 유흥주점 + 25만원 → 2개 규칙 해당
|
||||
|
||||
### 캐싱
|
||||
- sam_stat 테이블 5분 캐시 (백엔드 기존 구현)
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고 계산 공식 (p58-60)
|
||||
|
||||
### 가지급금 인정이자
|
||||
- 인정이자율: 4.6% (당좌대출이자율 기준, 매년 고시)
|
||||
- 인정이자 = 가지급금 × 일이자율(연이자율/365) × 경과일수
|
||||
- 법인세 추가: 인정이자 × 0.19
|
||||
- 대표자 소득세 추가: 인정이자 × 0.35
|
||||
|
||||
### 접대비 손금한도
|
||||
- 기본한도: 일반법인 1,200만원/년, 중소기업 3,600만원/년
|
||||
- 수입금액별 추가한도:
|
||||
- 100억 이하: 수입금액 × 0.2%
|
||||
- 100억~500억: 2,000만원 + (수입금액-100억) × 0.1%
|
||||
- 500억 초과: 6,000만원 + (수입금액-500억) × 0.03%
|
||||
|
||||
### 복리후생비 계산
|
||||
- 방식1 (직원당 정액): 직원수 × 월정액 × 12
|
||||
- 방식2 (연봉총액 비율): 연봉총액 × 비율%
|
||||
- 법정 복리후생비: 4대보험 회사부담분
|
||||
- 비과세 항목별 기준: 식대 20만원, 교통비 10만원, 경조사 5만원, 건강검진 월환산, 교육훈련 8만원, 복지포인트 10만원
|
||||
@@ -0,0 +1,281 @@
|
||||
# 계정과목(Chart of Accounts) 현황 분석 및 일반 ERP 비교
|
||||
|
||||
> 작성일: 2026-03-06
|
||||
> 목적: 회계담당자 피드백 기반, 현재 시스템 vs 일반 ERP 계정과목 체계 비교
|
||||
|
||||
---
|
||||
|
||||
## 1. 회계담당자 요구사항 요약
|
||||
|
||||
| # | 요구사항 | 핵심 |
|
||||
|---|---------|------|
|
||||
| 1 | 계정과목을 통일해서 관리 | 하나의 마스터에서 전사적 관리 |
|
||||
| 2 | 번호와 명칭으로 구분 | 코드 체계 필수 (예: 401-매출, 501-급여) |
|
||||
| 3 | 제조/회계 동일 명칭이지만 번호가 다른 경우 존재 | 부문별 세분화 필요 |
|
||||
| 4 | 등록하면 전체가 공유 + 개별등록도 가능 | 공통 + 부문별 계정 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 시스템 계정과목 사용 현황
|
||||
|
||||
### 2.1 모듈별 계정과목 관리 방식
|
||||
|
||||
| 모듈 | 계정과목 소스 | 옵션 수 | 관리 방식 | API 필드명 |
|
||||
|------|-------------|---------|----------|-----------|
|
||||
| **일반전표입력** | DB 마스터 (account_codes) | 동적 | API CRUD | `account_subject_id` |
|
||||
| **카드사용내역** | 프론트 하드코딩 | 16개 | 상수 배열 | `account_code` |
|
||||
| **미지급비용** | 프론트 하드코딩 | 9개 | 상수 배열 | `account_code` |
|
||||
| **매출관리** | 프론트 하드코딩 | 8개 | 상수 배열 | `account_code` |
|
||||
| **입금관리** | 프론트 하드코딩 | ~11개 | depositType 상수 | `account_code` |
|
||||
| **출금관리** | 프론트 하드코딩 | ~11개 | withdrawalType 상수 | `account_code` |
|
||||
| **세금계산서관리** | 프론트 하드코딩 | 11개 | 상수 배열 (분개 모달) | `account_subject` |
|
||||
| **CEO 대시보드** | 표시만 | - | account_title 표시 | `account_title` |
|
||||
|
||||
### 2.2 핵심 문제점
|
||||
|
||||
```
|
||||
[문제 1] 계정과목 이원화
|
||||
일반전표: DB 마스터 (code + name + category) ← 유일하게 정상
|
||||
나머지: 프론트엔드 하드코딩 상수 배열 ← 각자 따로 관리
|
||||
|
||||
[문제 2] 코드 체계 불일치
|
||||
일반전표: { code: "101", name: "현금", category: "asset" }
|
||||
카드내역: { value: "purchasePayment", label: "매입대금" } ← 영문 키워드
|
||||
입금관리: { value: "salesRevenue", label: "매출수금" } ← 또 다른 영문 키워드
|
||||
|
||||
[문제 3] 옵션 중복 + 불일치
|
||||
"급여"가 카드내역(salary), 미지급비용(salary), 입출금(salary)에 각각 존재
|
||||
세금계산서(분개)는 또 다른 옵션 세트 (매출, 부가세예수금 등)
|
||||
하지만 서로 독립적이라 추가/수정 시 각 파일 개별 수정 필요
|
||||
|
||||
[문제 4] 번호 체계 없음
|
||||
카드내역의 "매입대금" = 코드 없이 "purchasePayment"라는 문자열만 존재
|
||||
제조에서 쓰는 "재료비"와 회계에서 쓰는 "재료비"를 구분할 방법 없음
|
||||
```
|
||||
|
||||
### 2.3 백엔드 DB 구조 (현재)
|
||||
|
||||
```
|
||||
account_codes 테이블 (일반전표 전용 마스터)
|
||||
├── id (PK)
|
||||
├── tenant_id (테넌트 격리)
|
||||
├── code (varchar 10) ← 계정번호
|
||||
├── name (varchar 100) ← 계정명
|
||||
├── category (enum: asset/liability/capital/revenue/expense)
|
||||
├── sort_order
|
||||
├── is_active
|
||||
├── created_at / updated_at
|
||||
└── unique(tenant_id, code)
|
||||
|
||||
journal_entry_lines (분개 상세)
|
||||
├── account_code (varchar) ← 코드 저장
|
||||
├── account_name (varchar) ← 명칭 스냅샷 저장
|
||||
└── ... (side, amount 등)
|
||||
|
||||
barobill_card_transactions (카드거래)
|
||||
├── account_code (varchar) ← 문자열 직접 저장 ("purchasePayment" 등)
|
||||
└── ...
|
||||
|
||||
barobill_card_transaction_splits (카드 분개)
|
||||
├── account_code (varchar) ← 문자열 직접 저장
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 일반적인 ERP의 계정과목(Chart of Accounts) 체계
|
||||
|
||||
### 3.1 표준 구조
|
||||
|
||||
```
|
||||
[계정과목표 = Chart of Accounts]
|
||||
|
||||
계정분류(대분류)
|
||||
├── 1xxx: 자산 (Assets)
|
||||
│ ├── 11xx: 유동자산
|
||||
│ │ ├── 1101: 현금
|
||||
│ │ ├── 1102: 보통예금
|
||||
│ │ ├── 1103: 당좌예금
|
||||
│ │ ├── 1110: 매출채권
|
||||
│ │ └── 1120: 선급금
|
||||
│ └── 12xx: 비유동자산
|
||||
│ ├── 1201: 토지
|
||||
│ ├── 1202: 건물
|
||||
│ └── 1210: 기계장치
|
||||
│
|
||||
├── 2xxx: 부채 (Liabilities)
|
||||
│ ├── 21xx: 유동부채
|
||||
│ │ ├── 2101: 매입채무
|
||||
│ │ ├── 2102: 미지급금
|
||||
│ │ └── 2110: 예수금
|
||||
│ └── 22xx: 비유동부채
|
||||
│
|
||||
├── 3xxx: 자본 (Equity)
|
||||
│ ├── 3101: 자본금
|
||||
│ └── 3201: 이익잉여금
|
||||
│
|
||||
├── 4xxx: 수익 (Revenue)
|
||||
│ ├── 4101: 제품매출
|
||||
│ ├── 4102: 상품매출
|
||||
│ └── 4201: 임대수익
|
||||
│
|
||||
└── 5xxx: 비용 (Expenses)
|
||||
├── 51xx: 매출원가
|
||||
│ ├── 5101: 재료비 (제조) ← 코드로 구분!
|
||||
│ └── 5102: 노무비
|
||||
├── 52xx: 판매비와관리비
|
||||
│ ├── 5201: 급여
|
||||
│ ├── 5202: 복리후생비
|
||||
│ ├── 5203: 접대비
|
||||
│ ├── 5210: 재료비 (관리) ← 같은 명칭, 다른 코드!
|
||||
│ └── 5220: 임차료
|
||||
└── 53xx: 영업외비용
|
||||
├── 5301: 이자비용
|
||||
└── 5302: 외환차손
|
||||
```
|
||||
|
||||
### 3.2 일반 ERP 계정과목 마스터 구조
|
||||
|
||||
```
|
||||
account_subjects (계정과목 마스터)
|
||||
├── id (PK)
|
||||
├── code (varchar 10) ← "5101" 같은 번호 (4~6자리)
|
||||
├── name (varchar 100) ← "재료비"
|
||||
├── category (대분류) ← 자산/부채/자본/수익/비용
|
||||
├── sub_category (중분류) ← 유동자산/비유동자산/매출원가/판관비 등
|
||||
├── parent_code (상위 계정) ← 계층 구조용
|
||||
├── depth (계층 깊이) ← 1=대, 2=중, 3=소
|
||||
├── department_type (부문) ← 제조/관리/공통 등
|
||||
├── is_control (통제계정) ← 하위 세부계정 존재 여부
|
||||
├── is_active (사용여부)
|
||||
├── sort_order
|
||||
├── description (설명)
|
||||
└── tenant_id
|
||||
```
|
||||
|
||||
### 3.3 일반 ERP vs 현재 SAM ERP 비교
|
||||
|
||||
| 항목 | 일반 ERP | SAM ERP (현재) | 차이 |
|
||||
|------|---------|---------------|------|
|
||||
| **마스터 테이블** | 1개 (전사 공유) | 1개 있지만 일반전표만 사용 | 다른 모듈 미연동 |
|
||||
| **코드 체계** | 4~6자리 숫자 (1101, 5201) | 일반전표만 code 있음, 나머지 영문 키워드 | 번호 체계 불통일 |
|
||||
| **계층 구조** | 대-중-소 분류 (parent_code) | 대분류(5개)만 존재 | 중/소분류 없음 |
|
||||
| **부문 구분** | department_type으로 제조/관리 분리 | 없음 | 제조vs회계 구분 불가 |
|
||||
| **공유 범위** | 전 모듈이 같은 마스터 참조 | 각 모듈 독자 관리 | 핵심 문제 |
|
||||
| **등록 방식** | 계정과목 설정 화면 1곳 | 일반전표 설정에서만 등록 | 접근성 제한 |
|
||||
| **사용처 추적** | 어떤 전표에서 사용되는지 추적 | 없음 | 감사 추적 불가 |
|
||||
| **잠금/보호** | 사용 중인 계정 삭제 방지 | 없음 | 데이터 무결성 위험 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 담당자 요구사항 vs 현재 시스템 GAP 분석
|
||||
|
||||
### 요구 1: "계정과목을 통일해서 관리"
|
||||
|
||||
```
|
||||
현재 상태:
|
||||
일반전표 → account_codes 테이블 (DB)
|
||||
세금계산서 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 11개) - 분개 모달
|
||||
카드내역 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 16개)
|
||||
미지급비용 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 9개)
|
||||
매출관리 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 8개)
|
||||
입금관리 → depositType 상수
|
||||
출금관리 → withdrawalType 상수
|
||||
|
||||
필요한 것:
|
||||
모든 모듈 → account_codes 테이블 (DB) 하나만 참조
|
||||
|
||||
GAP: 크다 (프론트 하드코딩 → DB 마스터 참조로 전환 필요)
|
||||
```
|
||||
|
||||
### 요구 2: "번호와 명칭으로 구분"
|
||||
|
||||
```
|
||||
현재 상태:
|
||||
일반전표: code="101", name="현금" ← 있음
|
||||
카드내역: value="salary", label="급여" ← 영문 키워드, 번호 없음
|
||||
|
||||
필요한 것:
|
||||
모든 곳에서: code="5201", name="급여" 형태로 표시
|
||||
UI에서: "5201 - 급여" 또는 "[5201] 급여" 식으로 코드+명칭 동시 표시
|
||||
|
||||
GAP: 중간 (코드 체계는 DB에 이미 있으나, 다른 모듈이 참조하지 않음)
|
||||
```
|
||||
|
||||
### 요구 3: "제조/회계 동일 명칭, 번호로 구분"
|
||||
|
||||
```
|
||||
현재 상태:
|
||||
구분 불가. "재료비"가 제조인지 관리인지 알 방법 없음
|
||||
|
||||
필요한 것:
|
||||
5101: 재료비 (제조 - 매출원가)
|
||||
5210: 재료비 (판관비 - 관리비용)
|
||||
→ 코드가 다르므로 자동 구분
|
||||
|
||||
GAP: 크다 (중분류 + 부문 구분 필드 추가 필요)
|
||||
```
|
||||
|
||||
### 요구 4: "전체 공유 + 개별 등록 가능"
|
||||
|
||||
```
|
||||
현재 상태:
|
||||
일반전표 설정에서만 등록 가능. 다른 모듈은 하드코딩이라 등록 개념 없음.
|
||||
|
||||
필요한 것:
|
||||
- 기본 계정과목표 (회사 설정 시 일괄 생성)
|
||||
- 추가 등록 (필요에 따라 개별 계정과목 추가)
|
||||
- 전 모듈 공유 (등록 즉시 카드, 입출금, 세금계산서 등에서 사용 가능)
|
||||
|
||||
GAP: 중간 (DB 마스터는 있으니, 다른 모듈이 참조하도록 연결만 하면 됨)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 결론 및 권장사항
|
||||
|
||||
### 5.1 담당자 말씀이 맞는가?
|
||||
|
||||
**맞습니다.** 일반적인 ERP에서 계정과목은 반드시:
|
||||
- 하나의 마스터(Chart of Accounts)로 전사 통합 관리
|
||||
- 숫자 코드 + 명칭으로 식별 (코드가 PK 역할)
|
||||
- 코드 번호로 계정 분류/부문 구분 (제조 5101 vs 관리 5210)
|
||||
- 한 번 등록하면 모든 회계 모듈에서 공유
|
||||
|
||||
현재 SAM ERP는 일반전표에만 정상적인 마스터가 있고, 나머지는 각자 하드코딩이므로
|
||||
**회계적으로 올바르지 않은 상태**입니다.
|
||||
|
||||
### 5.2 개선 방향 (단계별)
|
||||
|
||||
```
|
||||
[Phase 1] 계정과목 마스터 강화 (백엔드)
|
||||
- account_codes 테이블에 sub_category, parent_code, depth, department_type 추가
|
||||
- 표준 계정과목표 시드 데이터 준비 (대/중/소 분류)
|
||||
- 코드 체계 확정 (4자리 vs 6자리)
|
||||
|
||||
[Phase 2] 계정과목 설정 화면 독립 (프론트)
|
||||
- 일반전표 내부 모달 → 독립 메뉴로 분리 (회계 > 계정과목 설정)
|
||||
- 계층 구조 표시 (트리뷰 또는 들여쓰기 목록)
|
||||
- 대량 등록 (Excel import), 기본 계정과목표 초기 세팅
|
||||
|
||||
[Phase 3] 전 모듈 통합 (프론트 + 백엔드)
|
||||
- 세금계산서관리: ACCOUNT_SUBJECT_OPTIONS 상수 (11개) → DB 마스터 API 호출로 전환
|
||||
- 카드사용내역: ACCOUNT_SUBJECT_OPTIONS 상수 (16개) → DB 마스터 API 호출로 전환
|
||||
- 입금/출금관리: depositType/withdrawalType → DB 마스터 참조로 전환
|
||||
- 미지급비용, 매출관리: 동일하게 전환
|
||||
- Select UI에 "코드 - 명칭" 형태로 표시 (예: "[5201] 급여")
|
||||
|
||||
[Phase 4] 고급 기능
|
||||
- 사용중 계정 삭제 방지 (참조 무결성)
|
||||
- 계정과목별 거래 내역 조회
|
||||
- 기간별 잔액 집계
|
||||
```
|
||||
|
||||
### 5.3 작업 규모 예상
|
||||
|
||||
| Phase | 범위 | 핵심 변경 |
|
||||
|-------|------|----------|
|
||||
| 1 | 백엔드 마이그레이션 + 시드 | account_codes 테이블 확장, 시드 데이터 |
|
||||
| 2 | 프론트 1개 페이지 신규 | 계정과목 설정 독립 페이지 |
|
||||
| 3 | 프론트 6~7개 모듈 수정, 백엔드 API 조정 | 하드코딩 → API 참조 전환 |
|
||||
| 4 | 양쪽 추가 개발 | 무결성, 집계, 조회 |
|
||||
@@ -0,0 +1,103 @@
|
||||
# 문서스냅샷 시스템 (Lazy Snapshot)
|
||||
|
||||
> **작업일**: 2026-03-06 ~ 03-07
|
||||
> **상태**: ✅ 완료
|
||||
> **커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템.
|
||||
MNG 측에서 문서 인쇄 시 스냅샷 기반 렌더링에 활용.
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
[문서 저장 시]
|
||||
컴포넌트 → contentWrapperRef.innerHTML 캡처
|
||||
→ API 요청에 rendered_html 파라미터 포함 → 백엔드 저장
|
||||
|
||||
[문서 조회 시 — Lazy Snapshot]
|
||||
rendered_html === NULL 감지
|
||||
→ 500ms 대기 (렌더링 완료 대기)
|
||||
→ innerHTML 캡처
|
||||
→ 백그라운드 PATCH 전송 (비차단)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 수동 캡처 (저장 시)
|
||||
|
||||
문서 저장 시 DOM에서 `innerHTML`을 읽어 `rendered_html` 파라미터로 함께 전송.
|
||||
|
||||
- [x] 검사성적서 (InspectionReportModal) — `contentWrapperRef.innerHTML`
|
||||
- [x] 작업일지 (WorkLogModal) — `contentWrapperRef.innerHTML`
|
||||
- [x] 수입검사 (ImportInspectionInputModal) — 오프스크린 렌더링 방식
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx`
|
||||
- `src/components/production/WorkerScreen/WorkLogModal.tsx`
|
||||
- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 2. Lazy Snapshot (조회 시 자동 캡처)
|
||||
|
||||
`rendered_html`이 NULL인 기존 문서를 조회할 때 자동으로 스냅샷을 캡처하여 백그라운드 저장.
|
||||
|
||||
### 동작 흐름
|
||||
1. 문서 조회 API 응답에서 `snapshot_document_id` 확인
|
||||
2. `rendered_html === NULL` → Lazy Snapshot 트리거
|
||||
3. 500ms 지연 (콘텐츠 렌더링 완료 대기)
|
||||
4. `contentWrapperRef.innerHTML` 캡처
|
||||
5. `patchDocumentSnapshot()` 서버 액션으로 백그라운드 PATCH
|
||||
|
||||
### 특성
|
||||
- **비차단(non-blocking)**: UI에 영향 없이 백그라운드 처리
|
||||
- **1회성**: 스냅샷 저장 후 재조회 시 캡처하지 않음
|
||||
- **readOnly 자동 캡처 제거**: 불필요한 PUT 요청 방지
|
||||
|
||||
### 적용 대상
|
||||
| 문서 | 수동 캡처 | Lazy Snapshot |
|
||||
|------|-----------|---------------|
|
||||
| 검사성적서 | ✅ | ✅ |
|
||||
| 작업일지 | ✅ | ✅ |
|
||||
| 수입검사 | ✅ (오프스크린) | — |
|
||||
| 제품검사 요청서 | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 오프스크린 렌더링 유틸리티
|
||||
|
||||
폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처하기 위한 유틸리티.
|
||||
|
||||
```typescript
|
||||
// src/lib/utils/capture-rendered-html.tsx
|
||||
// 오프스크린 DOM에 문서 컴포넌트를 렌더링하여 innerHTML 추출
|
||||
```
|
||||
|
||||
- [x] 수입검사 모달에서 활용 (폼 캡처 → 문서 캡처 전환)
|
||||
- [x] DocumentViewer 스냅샷 렌더링 지원
|
||||
|
||||
### 주요 파일
|
||||
- `src/lib/utils/capture-rendered-html.tsx` (신규)
|
||||
- `src/components/document-system/viewer/DocumentViewer.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 4. 서버 액션
|
||||
|
||||
```typescript
|
||||
// patchDocumentSnapshot — 백그라운드 PATCH
|
||||
export async function patchDocumentSnapshot(
|
||||
documentId: string,
|
||||
rendered_html: string
|
||||
): Promise<{ success: boolean }>;
|
||||
```
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/production/WorkOrders/actions.ts` — `patchDocumentSnapshot`
|
||||
- `src/components/quality/InspectionManagement/fqcActions.ts` — `patchDocumentSnapshot`
|
||||
38
claudedocs/backend/2026-03-02_구현내역.md
Normal file
38
claudedocs/backend/2026-03-02_구현내역.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 2026-03-02 (월) 백엔드 구현 내역
|
||||
|
||||
## 1. `🆕 신규` [roadmap] 중장기 계획 테이블 마이그레이션 추가
|
||||
|
||||
**커밋**: `3ca161e` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
관리자 패널에서 프로젝트 로드맵을 관리할 수 있도록 데이터베이스 테이블이 필요했음.
|
||||
|
||||
### 구현 내용
|
||||
- `admin_roadmap_plans` 테이블 생성 — 계획 마스터 (제목, 카테고리, 상태, Phase, 진행률)
|
||||
- `admin_roadmap_milestones` 테이블 생성 — 마일스톤 관리 (plan_id FK, 상태, 예정일)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 2. `🆕 신규` [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
|
||||
|
||||
**커밋**: `abe0460` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
AI 기반 자동 견적 시스템을 위한 데이터 저장 구조 및 초기 모듈 카탈로그 데이터가 필요했음.
|
||||
|
||||
### 구현 내용
|
||||
- `ai_quotation_modules` 테이블 — SAM 모듈 카탈로그 (18개 모듈 정의)
|
||||
- `ai_quotations` 테이블 — AI 견적 요청/결과 저장
|
||||
- `ai_quotation_items` 테이블 — AI 추천 모듈 목록
|
||||
- `AiQuotationModuleSeeder` — customer-pricing 기반 초기 데이터 시딩
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_02_100000_create_ai_quotation_tables.php` | 신규 생성 |
|
||||
| `database/seeders/AiQuotationModuleSeeder.php` | 신규 생성 |
|
||||
197
claudedocs/backend/2026-03-03_구현내역.md
Normal file
197
claudedocs/backend/2026-03-03_구현내역.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# 2026-03-03 (화) 백엔드 구현 내역
|
||||
|
||||
## 1. `⚙️ 설정` [ai] Gemini 모델 버전 업그레이드
|
||||
|
||||
**커밋**: `f79d008` | **유형**: chore
|
||||
|
||||
### 배경
|
||||
Google Gemini 모델의 새 버전(2.5-flash)이 출시되어 기존 2.0-flash에서 업그레이드 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `config/services.php` — fallback 기본 모델명 `gemini-2.5-flash`로 변경
|
||||
- `AiReportService.php` — fallback 기본값 동일 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `config/services.php` | 수정 |
|
||||
| `app/Services/AiReportService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 2. `🔧 수정` [deploy] 배포 시 .env 권한 640 보장 추가
|
||||
|
||||
**커밋**: `7e309e4` | **유형**: fix
|
||||
|
||||
### 배경
|
||||
2026-03-03 장애 발생 — vi 편집으로 `.env` 파일 권한이 600으로 변경되어 PHP-FPM이 읽기 실패 → 500 에러. 재발 방지를 위해 배포 파이프라인에 권한 보장 로직 추가.
|
||||
|
||||
### 구현 내용
|
||||
- Stage/Production Jenkinsfile 배포 스크립트에 `chmod 640 .env` 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `Jenkinsfile` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `🔧 수정` [hr] 사업소득자 임금대장 컬럼 추가
|
||||
|
||||
**커밋**: `b3c7d08` | **유형**: feat (기존 테이블 확장)
|
||||
|
||||
### 배경
|
||||
사업소득자(프리랜서)를 시스템 회원이 아닌 직접 입력 대상자로 지원하기 위해 추가 컬럼 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `user_id` nullable 변경 (직접 입력 대상자 지원)
|
||||
- `display_name`, `business_reg_number` 컬럼 추가
|
||||
- 기존 데이터는 earner 프로필에서 자동 채움 마이그레이션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/..._add_display_name_to_business_income_payments.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 4. `🔧 수정` [ai-quotation] 제조 견적서 마이그레이션 추가
|
||||
|
||||
**커밋**: `da1142a` | **유형**: feat (기존 테이블 확장)
|
||||
|
||||
### 배경
|
||||
AI 견적 시스템에서 제조업 견적서를 지원하기 위해 기존 테이블 확장 및 가격표 테이블 신규 생성 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `ai_quotations` 테이블에 `quote_mode`, `quote_number`, `product_category` 컬럼 추가
|
||||
- `ai_quotation_items` 테이블에 `specification`, `unit`, `quantity`, `unit_price`, `total_price`, `item_category`, `floor_code` 컬럼 추가
|
||||
- `ai_quote_price_tables` 테이블 신규 생성
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/..._add_manufacture_fields_to_ai_quotations.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 5. `🔧 수정` [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
|
||||
|
||||
**커밋**: `83a7745` | **유형**: feat (기존 기능 확장)
|
||||
|
||||
### 배경
|
||||
오늘의 이슈를 특정 날짜 기준으로 과거 데이터도 조회할 수 있어야 함. 이전에는 현재 날짜 기준만 지원했음.
|
||||
|
||||
### 구현 내용
|
||||
- `TodayIssueController`에 `date` 파라미터(YYYY-MM-DD) 추가
|
||||
- `TodayIssueService.summary()`에 날짜 기반 필터링 로직 구현
|
||||
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/TodayIssueController.php` | 수정 |
|
||||
| `app/Services/TodayIssueService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 6. `🔧 수정` [approval] 결재 수신함 날짜 범위 필터 추가
|
||||
|
||||
**커밋**: `b7465be` | **유형**: feat (기존 기능 확장)
|
||||
|
||||
### 배경
|
||||
결재 수신함에서 특정 기간의 결재 건만 조회할 수 있도록 날짜 필터 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `InboxIndexRequest`에 `start_date`/`end_date` 검증 룰 추가
|
||||
- `ApprovalService.inbox()`에 `created_at` 날짜 범위 필터 구현
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/Approval/InboxIndexRequest.php` | 수정 |
|
||||
| `app/Services/ApprovalService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 7. `🔧 수정` [daily-report] 자금현황 카드용 필드 추가
|
||||
|
||||
**커밋**: `ad27090` | **유형**: feat (기존 API 확장)
|
||||
|
||||
### 배경
|
||||
일일보고서 대시보드에 자금현황 카드를 표시하기 위해 미수금/미지급금/당월 예상 지출 데이터 필요.
|
||||
|
||||
### 구현 내용
|
||||
- 미수금 잔액(`receivable_balance`) 계산 로직 구현
|
||||
- 미지급금 잔액(`payable_balance`) 계산 로직 구현
|
||||
- 당월 예상 지출(`monthly_expense_total`) 계산 로직 구현
|
||||
- summary API 응답에 자금현황 3개 필드 포함
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/DailyReportService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 8. `🔧 수정` [stock,client,status-board] 날짜 필터 및 조건 보완
|
||||
|
||||
**커밋**: `4244334` | **유형**: feat (기존 기능 확장)
|
||||
|
||||
### 배경
|
||||
재고/거래처/현황판 화면에서 날짜 범위 필터가 미지원이었고, 부실채권 현황에 비활성 데이터가 포함되는 이슈.
|
||||
|
||||
### 구현 내용
|
||||
- `StockController/StockService` — 입출고 이력 기반 날짜 범위 필터 추가
|
||||
- `ClientService` — 등록일 기간 필터(`start_date`/`end_date`) 추가
|
||||
- `StatusBoardService` — 부실채권 현황에 `is_active` 조건 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/StockController.php` | 수정 |
|
||||
| `app/Services/StockService.php` | 수정 |
|
||||
| `app/Services/ClientService.php` | 수정 |
|
||||
| `app/Services/StatusBoardService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. `🔧 수정` [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
|
||||
|
||||
**커밋**: `23c6cf6` | **유형**: feat (기존 모델 확장)
|
||||
|
||||
### 배경
|
||||
기존 연차/반차만 지원하던 휴가 시스템에 출장, 재택근무, 외근, 조퇴, 지각, 결근 등 근태 유형 확장 필요. 결재 양식(근태신청, 사유서)도 추가.
|
||||
|
||||
### 구현 내용
|
||||
- Leave 타입 6개 추가: `business_trip`, `remote`, `field_work`, `early_leave`, `late_reason`, `absent_reason`
|
||||
- 그룹 상수: `VACATION_TYPES`, `ATTENDANCE_REQUEST_TYPES`, `REASON_REPORT_TYPES`
|
||||
- `FORM_CODE_MAP` — 유형 → 결재양식코드 매핑
|
||||
- `ATTENDANCE_STATUS_MAP` — 유형 → 근태상태 매핑
|
||||
- 결재양식 2개 추가: `attendance_request`(근태신청), `reason_report`(사유서)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Tenants/Leave.php` | 수정 |
|
||||
| `database/migrations/..._insert_attendance_approval_forms.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 10. `🔧 수정` [production] 자재투입 모달 개선
|
||||
|
||||
**커밋**: `fc53789` | **유형**: fix (기존 기능 버그 수정 + 개선)
|
||||
|
||||
### 배경
|
||||
자재투입 시 lot 미관리 품목(L-Bar, 보강평철)이 목록에 표시되는 이슈, BOM 그룹키 부재로 동일 자재 구분 불가, 셔터박스 순서가 작업일지와 불일치.
|
||||
|
||||
### 구현 내용
|
||||
- `getMaterialsForItem` — `lot_managed===false` 품목을 자재투입 목록에서 제외
|
||||
- `getMaterialsForItem` — `bom_group_key` 필드 추가 (category+partType 기반 고유키)
|
||||
- `BendingInfoBuilder` — `shutterPartTypes`에서 `top_cover`/`fin_cover` 제거 (중복 방지)
|
||||
- `BendingInfoBuilder` — 셔터박스 루프 순서 파트→길이로 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
|
||||
| `app/Services/WorkOrderService.php` | 수정 |
|
||||
336
claudedocs/backend/2026-03-04_구현내역.md
Normal file
336
claudedocs/backend/2026-03-04_구현내역.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# 2026-03-04 (수) 백엔드 구현 내역
|
||||
|
||||
## 1. `🔧 수정` [inspection] 캘린더 스케줄 조회 API 추가
|
||||
|
||||
**커밋**: `e9fd75f` | **유형**: feat (기존 검사 모듈에 캘린더 API 추가)
|
||||
|
||||
### 배경
|
||||
검사 일정을 캘린더 형태로 표시하기 위한 API 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `GET /api/v1/inspections/calendar` 엔드포인트 추가
|
||||
- `year`, `month`, `inspector`, `status` 파라미터 지원
|
||||
- React 프론트엔드 `CalendarItemApi` 형식에 맞춰 응답
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/InspectionController.php` | 수정 |
|
||||
| `app/Services/InspectionService.php` | 수정 |
|
||||
| `routes/api/v1/production.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 2. `🆕 신규` [barobill] 바로빌 연동 API 엔드포인트 추가
|
||||
|
||||
**커밋**: `4f3467c` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
바로빌(전자세금계산서/은행/카드 연동 서비스) API 연동을 위한 백엔드 엔드포인트 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `GET /api/v1/barobill/status` — 연동 현황 조회
|
||||
- `POST /api/v1/barobill/login` — 로그인 정보 등록
|
||||
- `POST /api/v1/barobill/signup` — 회원가입 정보 등록
|
||||
- `GET /api/v1/barobill/bank-service-url` — 은행 서비스 URL
|
||||
- `GET /api/v1/barobill/account-link-url` — 계좌 연동 URL
|
||||
- `GET /api/v1/barobill/card-link-url` — 카드 연동 URL
|
||||
- `GET /api/v1/barobill/certificate-url` — 공인인증서 URL
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/BarobillController.php` | 신규 생성 |
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `🔧 수정` [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
|
||||
|
||||
**커밋**: `1deeafc` | **유형**: feat (기존 대시보드 확장)
|
||||
|
||||
### 배경
|
||||
경비/가지급금 대시보드에서 날짜 범위 필터와 검색 기능이 없었고, 가지급금에 카테고리(카드/경조사/상품권/접대비) 분류 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `ExpectedExpenseController/Service` — dashboardDetail에 `start_date`/`end_date`/`search` 파라미터 추가
|
||||
- `Loan` 모델 — category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
|
||||
- `LoanService` — dashboard에 `category_breakdown` 집계 추가
|
||||
- 마이그레이션 — loans 테이블 `category` 컬럼 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/ExpectedExpenseController.php` | 수정 |
|
||||
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||
| `app/Services/ExpectedExpenseService.php` | 수정 |
|
||||
| `app/Services/LoanService.php` | 수정 |
|
||||
| `database/migrations/2026_03_04_100000_add_category_to_loans_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 4. `🔧 수정` [models] User 모델 import 누락/오류 수정
|
||||
|
||||
**커밋**: `da04b84` | **유형**: fix (버그 수정)
|
||||
|
||||
### 배경
|
||||
Tenants 네임스페이스에서 `User::class`가 `App\Models\Tenants\User`로 잘못 해석되는 문제. Loan, TodayIssue 모델에서 User import 경로 오류.
|
||||
|
||||
### 구현 내용
|
||||
- `Loan.php` — `App\Models\Members\User` import 추가
|
||||
- `TodayIssue.php` — `App\Models\Users\User` → `App\Models\Members\User` 수정
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||
| `app/Models/Tenants/TodayIssue.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 5. `🔧 수정` [cards] 리다이렉트 추가
|
||||
|
||||
**커밋**: `76192fc` | **유형**: fix (하위호환)
|
||||
|
||||
### 배경
|
||||
프론트엔드에서 기존 `cards/stats` 경로로 호출하는 코드가 있어 새 경로로 리다이렉트 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `cards/stats` → `card-transactions/dashboard` 리다이렉트 라우트 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 6. `🔧 수정` [address] 주소 필드 255자 → 500자 확장
|
||||
|
||||
**커밋**: `7cf70db` | **유형**: fix (제한 완화)
|
||||
|
||||
### 배경
|
||||
실제 주소 데이터가 255자를 초과하는 경우 발생. DB와 FormRequest 검증 모두 확장 필요.
|
||||
|
||||
### 구현 내용
|
||||
- DB 마이그레이션 — `clients`, `tenants`, `site_briefings`, `sites` 테이블 address 컬럼 `varchar(500)`
|
||||
- FormRequest 8개 파일 — `max:255` → `max:500` 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/Client/ClientStoreRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Client/ClientUpdateRequest.php` | 수정 |
|
||||
| `app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php` | 수정 |
|
||||
| `app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Tenant/TenantStoreRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Tenant/TenantUpdateRequest.php` | 수정 |
|
||||
| `app/Http/Requests/V1/Site/StoreSiteRequest.php` | 수정 |
|
||||
| `app/Http/Requests/V1/Site/UpdateSiteRequest.php` | 수정 |
|
||||
| `database/migrations/..._extend_address_columns_to_500.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. `🔧 수정` [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
|
||||
|
||||
**커밋**: `e637e3d` | **유형**: feat (기존 대시보드 대규모 리팩토링)
|
||||
|
||||
### 배경
|
||||
D1.7 기획서 요구사항에 따라 접대비/복리후생비/매출채권 대시보드를 단순 집계에서 리스크 감지형으로 전환.
|
||||
|
||||
### 구현 내용
|
||||
- `EntertainmentService` — 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
|
||||
- `WelfareService` — 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
|
||||
- `ReceivablesService` — summary를 `cards` + `check_points` 구조로 개선 (누적/당월 미수금, Top3 거래처)
|
||||
- `LoanService` — getCategoryBreakdown 전체 대상으로 집계 조건 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
|
||||
| `app/Services/WelfareService.php` | 수정 (대규모) |
|
||||
| `app/Services/ReceivablesService.php` | 수정 (대규모) |
|
||||
| `app/Services/LoanService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 8. `🔧 수정` [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
|
||||
|
||||
**커밋**: `f665d3a` | **유형**: fix (버그 수정)
|
||||
|
||||
### 배경
|
||||
바로빌 카드거래 테이블 조인 시 컬럼명 불일치 및 심야 판별 함수 오류.
|
||||
|
||||
### 구현 내용
|
||||
- `approval_no` → `approval_num` 컬럼명 수정
|
||||
- `use_time` 심야 판별: `HOUR()` → `SUBSTRING` 문자열 파싱으로 변경
|
||||
- `whereNotNull('bct.use_time')` 조건 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/EntertainmentService.php` | 수정 |
|
||||
| `app/Services/WelfareService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. `🆕 신규` [approval] 지출결의서 양식 등록 및 고도화
|
||||
|
||||
**커밋**: `b86af29`, `282bf26` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
전자결재에 지출결의서 양식을 등록하고, HTML body_template 필드로 정형화된 양식 제공.
|
||||
|
||||
### 구현 내용
|
||||
- `approval_forms` 테이블에 `body_template` TEXT 컬럼 추가 (마이그레이션)
|
||||
- 지출결의서(expense) 양식 데이터 등록
|
||||
- 참조 문서 기반으로 정형 양식 HTML 리디자인 — 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/..._add_body_template_to_approval_forms.php` | 신규 생성 |
|
||||
| `database/migrations/..._insert_expense_approval_form.php` | 신규 생성 |
|
||||
| `database/migrations/..._update_expense_approval_form_body_template.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 10. `🆕 신규` [entertainment] 접대비 상세 조회 API + `🔧 수정` 가지급금 날짜 필터
|
||||
|
||||
**커밋**: `66da297`, `a173a5a`, `94b96e2`, `2f3ec13` | **유형**: feat + fix
|
||||
|
||||
### 배경
|
||||
접대비 상세 대시보드(손금한도, 월별추이, 거래내역)가 필요하고, 가지급금 대시보드에도 날짜 필터 지원 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `EntertainmentController/Service` — `getDetail()` 상세 조회 API 신규 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
|
||||
- 수입금액별 추가한도 계산 (세법 기준), 거래건별 리스크 감지
|
||||
- `LoanController/Service` — dashboard에 `start_date`/`end_date` 파라미터 지원 (기존 수정)
|
||||
- `getCategoryBreakdown` SQL alias 충돌 수정
|
||||
- 분기 사용액 조회에 날짜 필터 적용
|
||||
- 라우트: `GET /entertainment/detail` 엔드포인트 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/EntertainmentController.php` | 수정 |
|
||||
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
|
||||
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
|
||||
| `app/Services/LoanService.php` | 수정 |
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 11. `🆕 신규` [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API
|
||||
|
||||
**커밋**: `74a60e0` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
일정 관리를 위한 캘린더 CRUD API와 부가세 상세 조회 대시보드 API 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `CalendarController/Service` — 일정 등록/수정/삭제 API 신규
|
||||
- `VatController/Service` — `getDetail()` 상세 조회 신규 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
|
||||
- 라우트: `POST/PUT/DELETE /calendar/schedules`, `GET /vat/detail`
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/CalendarController.php` | 신규 생성 |
|
||||
| `app/Http/Controllers/Api/V1/VatController.php` | 신규 생성 |
|
||||
| `app/Services/CalendarService.php` | 신규 생성 |
|
||||
| `app/Services/VatService.php` | 신규 생성 |
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 12. `🆕 신규` [shipment] 배차정보 다중 행 시스템
|
||||
|
||||
**커밋**: `851862` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
기존 출하 건에 단일 배차정보만 저장 가능했으나, 다중 차량 배차를 지원해야 함.
|
||||
|
||||
### 구현 내용
|
||||
- `shipment_vehicle_dispatches` 테이블 신규 생성 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
|
||||
- `ShipmentVehicleDispatch` 모델 신규
|
||||
- `Shipment` 모델에 `vehicleDispatches()` HasMany 관계 추가
|
||||
- `ShipmentService` — `syncDispatches()` 추가, store/update/delete/show/index에서 연동
|
||||
- FormRequest — Store/Update에 `vehicle_dispatches` 배열 검증 규칙 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 신규 생성 |
|
||||
| `app/Models/Tenants/Shipment.php` | 수정 |
|
||||
| `app/Services/ShipmentService.php` | 수정 |
|
||||
| `app/Http/Requests/Shipment/ShipmentStoreRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Shipment/ShipmentUpdateRequest.php` | 수정 |
|
||||
| `database/migrations/..._create_shipment_vehicle_dispatches_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 13. `🔧 수정` [production] 자재투입 bom_group_key 개별 저장
|
||||
|
||||
**커밋**: `5ee97c2` | **유형**: fix (기존 기능 보완)
|
||||
|
||||
### 배경
|
||||
동일 자재가 다른 BOM 그룹에 속할 때 구분이 안 되는 문제. bom_group_key로 개별 식별 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `work_order_material_inputs` 테이블에 `bom_group_key` 컬럼 추가
|
||||
- 기투입 조회를 `stock_lot_id` + `bom_group_key` 복합키로 변경
|
||||
- `replace` 모드 지원 (기존 삭제 → 재등록)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php` | 수정 |
|
||||
| `app/Models/Production/WorkOrderMaterialInput.php` | 수정 |
|
||||
| `app/Services/WorkOrderService.php` | 수정 |
|
||||
| `database/migrations/..._bom_group_key_to_work_order_material_inputs.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 14. `🔧 수정` [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
|
||||
|
||||
**커밋**: `897511c` | **유형**: fix (기존 검사 로직 개선)
|
||||
|
||||
### 배경
|
||||
절곡 검사 시 동일 작업지시의 모든 item에 검사 데이터가 복제 저장되어야 하며, products 배열을 bending EAV 레코드로 변환 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `storeItemInspection` — bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
|
||||
- `transformBendingProductsToRecords` — products 배열 → bending EAV 레코드 변환
|
||||
- `getMaterialInputLots` — 품목코드별 그룹핑으로 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/WorkOrderService.php` | 수정 (대규모) |
|
||||
|
||||
---
|
||||
|
||||
## 15. `🆕 신규` [outbound] 배차차량 관리 API
|
||||
|
||||
**커밋**: `1a8bb46` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
출고 관련 배차차량을 독립적으로 관리(조회/수정/통계)하는 API 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `VehicleDispatchService` — index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
|
||||
- `VehicleDispatchController` + `VehicleDispatchUpdateRequest`
|
||||
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
|
||||
- inventory.php에 `vehicle-dispatches` 라우트 4개 등록
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/VehicleDispatchController.php` | 신규 생성 |
|
||||
| `app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php` | 신규 생성 |
|
||||
| `app/Services/VehicleDispatchService.php` | 신규 생성 |
|
||||
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 수정 |
|
||||
| `app/Services/ShipmentService.php` | 수정 |
|
||||
| `database/migrations/..._options_to_shipment_vehicle_dispatches_table.php` | 신규 생성 |
|
||||
| `routes/api/v1/inventory.php` | 수정 |
|
||||
386
claudedocs/backend/2026-03-05_구현내역.md
Normal file
386
claudedocs/backend/2026-03-05_구현내역.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# 2026-03-05 (목) 백엔드 구현 내역
|
||||
|
||||
## 1. `🔧 수정` [storage] RecordStorageUsage 명령어 수정
|
||||
|
||||
**커밋**: `e0bb19a` | **유형**: fix (버그 수정)
|
||||
|
||||
### 배경
|
||||
`Tenant::where('status', 'active')` 하드코딩 사용 중이나 tenants 테이블에 `status` 컬럼이 없고 `tenant_st_code`를 사용함. 모델 스코프 사용으로 수정.
|
||||
|
||||
### 구현 내용
|
||||
- `Tenant::where('status', 'active')` → `Tenant::active()` 스코프 사용
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 2. `🆕 신규` [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀
|
||||
|
||||
**커밋**: `e8da2ea`, `f1a3e0f` | **유형**: feat + fix
|
||||
|
||||
### 배경
|
||||
CEO 전용 대시보드에 매출/매입/생산/미출고/시공/근태 등 6개 섹션 데이터를 제공하는 API 및 엑셀 다운로드 기능 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `DashboardCeoController/Service` — 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
|
||||
- `DailyReportController/Service` — 엑셀 다운로드 API (`GET /daily-report/export`)
|
||||
- 라우트: dashboard 하위 6개 + `daily-report/export` 엔드포인트
|
||||
- 공정명 컬럼 수정 (`p.name` → `p.process_name`)
|
||||
- 근태 부서 조인 수정 (`users.department_id` → `tenant_user_profiles` 경유)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/DashboardCeoController.php` | 신규 생성 |
|
||||
| `app/Services/DashboardCeoService.php` | 신규 생성 |
|
||||
| `app/Http/Controllers/Api/V1/DailyReportController.php` | 수정 |
|
||||
| `app/Services/DailyReportService.php` | 수정 |
|
||||
| `routes/api/v1/common.php` | 수정 |
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `🔧 수정` [daily-report] 엑셀 내보내기 어음/외상매출채권 현황 및 리팩토링
|
||||
|
||||
**커밋**: `1b2363d`, `fefd129` | **유형**: feat + refactor (기존 엑셀 기능 확장/개선)
|
||||
|
||||
### 배경
|
||||
일일보고서 엑셀에 어음/외상매출채권 현황 섹션이 빠져있었고, 엑셀과 화면 데이터가 불일치하는 문제.
|
||||
|
||||
### 구현 내용
|
||||
- `DailyReportExport` — 어음 현황 테이블 + 합계 + 스타일링 추가
|
||||
- `DailyReportService` — exportData를 `dailyAccounts()` 재사용 구조로 리팩토링
|
||||
- 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Exports/DailyReportExport.php` | 수정 |
|
||||
| `app/Services/DailyReportService.php` | 수정 (리팩토링) |
|
||||
|
||||
---
|
||||
|
||||
## 4. `🔧 수정` [production] 절곡 검사 FormRequest 검증 누락 수정
|
||||
|
||||
**커밋**: `ef7d9fa` | **유형**: fix (버그 수정)
|
||||
|
||||
### 배경
|
||||
`StoreItemInspectionRequest`에 `inspection_data.products` 검증 규칙이 누락되어 `validated()`에서 products 데이터가 제거되는 버그.
|
||||
|
||||
### 구현 내용
|
||||
- `products.*.id`, `bendingStatus`, `lengthMeasured`, `widthMeasured`, `gapPoints` 검증 규칙 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 5. `🆕 신규` [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
|
||||
|
||||
**커밋**: `cd847e0` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
문서(Document) 시스템과 결재(Approval) 시스템을 연동하여, 문서 상신 시 결재가 자동 생성되고 결재 처리 시 문서 상태가 동기화되어야 함.
|
||||
|
||||
### 구현 내용
|
||||
- `Approval` 모델에 `linkable` morphTo 관계 추가
|
||||
- `DocumentService` — 상신 시 Approval 자동 생성 + approval_steps 변환
|
||||
- `ApprovalService` — 승인/반려/회수 시 Document 상태 동기화
|
||||
- `approvals` 테이블에 `linkable_type`, `linkable_id` 컬럼 마이그레이션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Tenants/Approval.php` | 수정 |
|
||||
| `app/Services/ApprovalService.php` | 수정 |
|
||||
| `app/Services/DocumentService.php` | 수정 |
|
||||
| `database/migrations/..._add_linkable_to_approvals_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 6. `🔧 수정` [process] 공정단계 options 컬럼 추가
|
||||
|
||||
**커밋**: `1f7f45e` | **유형**: feat (기존 테이블 확장)
|
||||
|
||||
### 배경
|
||||
공정단계별 검사 설정/범위 등 확장 속성을 저장할 JSON 컬럼 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `ProcessStep` 모델에 `options` JSON 컬럼 추가 (fillable, cast)
|
||||
- Store/UpdateProcessStepRequest에 `inspection_setting`, `inspection_scope` 검증 규칙
|
||||
- `process_steps` 테이블 마이그레이션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php` | 수정 |
|
||||
| `app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php` | 수정 |
|
||||
| `app/Models/ProcessStep.php` | 수정 |
|
||||
| `database/migrations/..._add_options_to_process_steps_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. `🔄 리팩토링` [production] 셔터박스 prefix isStandard 파라미터 제거
|
||||
|
||||
**커밋**: `d4f21f0` | **유형**: refactor
|
||||
|
||||
### 배경
|
||||
CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize와 무관하게 적용됨. isStandard 분기가 불필요.
|
||||
|
||||
### 구현 내용
|
||||
- `resolveShutterBoxPrefix()`에서 `isStandard` 파라미터 및 분기 로직 제거
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
|
||||
| `app/Services/Production/PrefixResolver.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 8. `🔧 수정` [production] 자재투입 replace 모드 지원
|
||||
|
||||
**커밋**: `7432fb1` | **유형**: feat (기존 기능 확장)
|
||||
|
||||
### 배경
|
||||
자재투입 시 기존 투입 데이터를 교체하는 방식 선택 가능하도록 지원.
|
||||
|
||||
### 구현 내용
|
||||
- `registerMaterialInputForItem`에 `replace` 파라미터 추가
|
||||
- Controller에서 request body의 `replace` 값 전달
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/WorkOrderController.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 9. `🔄 리팩토링` [core] 모델 스코프 적용 규칙 추가
|
||||
|
||||
**커밋**: `9b8cdfa` | **유형**: refactor
|
||||
|
||||
### 배경
|
||||
`where` 하드코딩 대신 모델에 정의된 스코프를 우선 사용하도록 코드 규칙 명시.
|
||||
|
||||
### 구현 내용
|
||||
- `RecordStorageUsage` — where 하드코딩 → `Tenant::active()` 스코프
|
||||
- `CLAUDE.md` — 쿼리 수정 시 모델 스코프 우선 규칙 명시
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `CLAUDE.md` | 수정 |
|
||||
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 10. `⚙️ 설정` [infra] Slack 알림 채널 분리
|
||||
|
||||
**커밋**: `3d4dd9f` | **유형**: chore
|
||||
|
||||
### 배경
|
||||
배포 알림 채널을 product_infra에서 deploy_api로 분리하여 알림 관리 개선.
|
||||
|
||||
### 구현 내용
|
||||
- Jenkinsfile Slack 알림 채널 `product_infra` → `deploy_api` 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `Jenkinsfile` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 11. `🔧 수정` [approval] 결재 테이블 확장 (3건)
|
||||
|
||||
**커밋**: `ac72487`, `558a393`, `ce1f910` | **유형**: feat (기존 테이블 확장)
|
||||
|
||||
### 배경
|
||||
결재 시스템에 기안자 읽음 확인, 재상신 횟수, 반려 이력 추적 기능 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `drafter_read_at` 컬럼 — 기안자 완료 결과 확인 타임스탬프 (미읽음 뱃지 지원)
|
||||
- `resubmit_count` 컬럼 — 재상신 횟수 추적
|
||||
- `rejection_history` JSON 컬럼 — 반려 이력 저장
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/..._add_drafter_read_at_to_approvals_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._add_resubmit_count_to_approvals_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._add_rejection_history_to_approvals_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 12. `🆕 신규` [rd] CM송 저장 테이블 마이그레이션
|
||||
|
||||
**커밋**: `66d1004` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
AI 생성 CM송(광고 음악) 데이터 저장을 위한 테이블 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `cm_songs` 테이블 생성 — tenant_id, user_id, company_name, industry, lyrics, audio_path, options
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_05_170000_create_cm_songs_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 13. `🆕 신규` [approval] 결재양식 마이그레이션 (3건)
|
||||
|
||||
**커밋**: `f41605c`, `0f25a5d`, `846ced3` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
전자결재에 재직증명서, 경력증명서, 위촉증명서 양식 추가 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `employment_cert` — 재직증명서 양식 등록
|
||||
- `career_cert` — 경력증명서 양식 등록
|
||||
- `appointment_cert` — 위촉증명서 양식 등록
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_05_184507_add_employment_cert_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_05_230000_add_career_cert_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_05_234000_add_appointment_cert_form.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 14. `🔧 수정` [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리
|
||||
|
||||
**커밋**: `8c9f2fc` | **유형**: feat (기존 모델 대규모 확장)
|
||||
|
||||
### 배경
|
||||
어음 관리에 V8 규격(증권종류, 할인, 배서, 추심, 개서, 부도 등) 54개 필드 지원 필요. 가지급금에 상품권 카테고리 및 상태(보유/사용/폐기) 관리 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `Bill` 모델 — V8 확장 필드 54개 추가, 수취/발행 어음·수표별 세분화된 상태 체계
|
||||
- `BillService` — `assignV8Fields`/`syncInstallments` 헬퍼, instrument_type/medium 필터
|
||||
- `BillInstallment` — type/counterparty 필드 추가
|
||||
- `Loan` 모델 — holding/used/disposed 상태 + metadata(JSON) 필드
|
||||
- `LoanService` — 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
|
||||
- FormRequest — V8 확장 필드 검증 규칙
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Tenants/Bill.php` | 수정 (대규모) |
|
||||
| `app/Models/Tenants/BillInstallment.php` | 수정 |
|
||||
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||
| `app/Services/BillService.php` | 수정 |
|
||||
| `app/Services/LoanService.php` | 수정 |
|
||||
| `app/Http/Requests/V1/Bill/StoreBillRequest.php` | 수정 |
|
||||
| `app/Http/Requests/V1/Bill/UpdateBillRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Loan/LoanStoreRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Loan/LoanUpdateRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Loan/LoanIndexRequest.php` | 수정 |
|
||||
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
|
||||
| `database/migrations/..._add_v8_fields_to_bills_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._add_metadata_to_loans_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 15. `🆕 신규` [loan] 상품권 접대비 자동 연동 + `🔧 수정` 후속 수정 (5건)
|
||||
|
||||
**커밋**: `31d2f08`, `03f86f3`, `652ac3d`, `7fe856f`, `c57e768` | **유형**: feat + fix
|
||||
|
||||
### 배경
|
||||
상품권이 사용+접대비해당일 경우 expense_accounts에 자동으로 접대비 레코드를 생성/삭제해야 함. 관련 집계 및 수정/삭제 정책도 정비.
|
||||
|
||||
### 구현 내용
|
||||
- `ExpenseAccount` — `loan_id` 필드 + `SUB_TYPE_GIFT_CERTIFICATE` 상수 추가
|
||||
- `LoanService` — 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제 (🆕)
|
||||
- store()에서도 접대비 자동 연동 호출 (🔧)
|
||||
- `getCategoryBreakdown` — used/disposed 상품권은 가지급금 집계에서 제외 (🔧)
|
||||
- dashboard summary/목록에서도 used/disposed 상품권 제외 (🔧)
|
||||
- `isEditable()`/`isDeletable()` — 상품권이면 상태 무관하게 허용 (🔧)
|
||||
- 접대비 연동 시 `receipt_no`에 시리얼번호 매핑 (🔧)
|
||||
- `expense_accounts`에 `loan_id` 컬럼 마이그레이션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
|
||||
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||
| `app/Services/LoanService.php` | 수정 (다회) |
|
||||
| `database/migrations/..._add_loan_id_to_expense_accounts_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 16. `🆕 신규` [생산지시] 전용 API 엔드포인트 신규 생성 + `🔧 수정` 후속 수정 (4건)
|
||||
|
||||
**커밋**: `2df8ecf`, `59d13ee`, `38c2402`, `0aa0a85` | **유형**: feat + fix
|
||||
|
||||
### 배경
|
||||
수주 기반 생산지시 전용 API가 없어 프론트엔드에서 여러 API를 조합해야 했음. 전용 엔드포인트로 통합.
|
||||
|
||||
### 구현 내용
|
||||
- `ProductionOrderService` — 목록(index), 통계(stats), 상세(show) 구현 (🆕)
|
||||
- Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
|
||||
- `workOrderProgress` 가공 필드, `production_ordered_at` 첫 WO 기반
|
||||
- BOM 공정 분류 추출 (order_nodes.options.bom_result)
|
||||
- `ProductionOrderController` + `ProductionOrderIndexRequest` + Swagger 문서 (🆕)
|
||||
- 날짜 포맷 Y-m-d 변환, `withCount('nodes')` 개소수 추가 (🔧)
|
||||
- 자재투입 시 WO 자동 상태 전환 (`autoStartWorkOrderOnMaterialInput`) (🆕)
|
||||
- `process_id=null`인 구매품/서비스 WO 제외 (🔧)
|
||||
- `extractBomProcessGroups` BOM 파싱 수정 (🔧)
|
||||
- 재고생산 보조 공정을 일반 워크플로우에서 분리 (`is_auxiliary` 플래그) (🆕)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/ProductionOrderController.php` | 신규 생성 |
|
||||
| `app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php` | 신규 생성 |
|
||||
| `app/Services/ProductionOrderService.php` | 신규 생성 + 수정 |
|
||||
| `app/Swagger/v1/ProductionOrderApi.php` | 신규 생성 |
|
||||
| `app/Services/WorkOrderService.php` | 수정 |
|
||||
| `app/Services/OrderService.php` | 수정 |
|
||||
| `routes/api/v1/production.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 17. `🆕 신규` [품질관리] 백엔드 API 구현 + `🔧 수정` 후속 수정 (3건)
|
||||
|
||||
**커밋**: `a6e29bc`, `3600c7b`, `0f26ea5` | **유형**: feat + fix
|
||||
|
||||
### 배경
|
||||
품질관리서(제품검사 요청서) 및 실적신고 관리를 위한 백엔드 API 전체 구현.
|
||||
|
||||
### 구현 내용
|
||||
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트 (🆕)
|
||||
- 실적신고(performance_reports) 관리 API 6개 엔드포인트 (🆕)
|
||||
- DB 마이그레이션 4개 테이블 (🆕)
|
||||
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 (🆕)
|
||||
- 납품일 Y-m-d 포맷 변환, 개소 수 order_nodes 루트 노드 기준 변경 (🔧)
|
||||
- 수주선택 API에 `client_name` 필드 추가 (🔧)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/QualityDocumentController.php` | 신규 생성 |
|
||||
| `app/Http/Controllers/Api/V1/PerformanceReportController.php` | 신규 생성 |
|
||||
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 신규 생성 |
|
||||
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 신규 생성 |
|
||||
| `app/Http/Requests/Quality/PerformanceReportConfirmRequest.php` | 신규 생성 |
|
||||
| `app/Http/Requests/Quality/PerformanceReportMemoRequest.php` | 신규 생성 |
|
||||
| `app/Models/Qualitys/QualityDocument.php` | 신규 생성 |
|
||||
| `app/Models/Qualitys/QualityDocumentOrder.php` | 신규 생성 |
|
||||
| `app/Models/Qualitys/QualityDocumentLocation.php` | 신규 생성 |
|
||||
| `app/Models/Qualitys/PerformanceReport.php` | 신규 생성 |
|
||||
| `app/Services/QualityDocumentService.php` | 신규 생성 + 수정 |
|
||||
| `app/Services/PerformanceReportService.php` | 신규 생성 |
|
||||
| `database/migrations/..._create_quality_documents_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._create_quality_document_orders_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._create_quality_document_locations_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._create_performance_reports_table.php` | 신규 생성 |
|
||||
| `routes/api/v1/quality.php` | 신규 생성 |
|
||||
287
claudedocs/backend/2026-03-06_구현내역.md
Normal file
287
claudedocs/backend/2026-03-06_구현내역.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 2026-03-06 (금) 백엔드 구현 내역
|
||||
|
||||
## 1. `🔧 수정` [생산지시] 보조 공정 WO 카운트 제외
|
||||
|
||||
**커밋**: `a845f52` | **유형**: fix (기존 기능 보완)
|
||||
|
||||
### 배경
|
||||
목록 조회 시 `work_orders_count`에 보조 공정(재고생산) WO가 포함되어 공정 진행률이 부정확.
|
||||
|
||||
### 구현 내용
|
||||
- `withCount`에서 `is_auxiliary` WO 제외 조건 추가
|
||||
- `whereNotNull(process_id)` + `options->is_auxiliary` 조건 적용
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/ProductionOrderService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 2. `🔧 수정` [loan] 상품권 summary에 접대비 집계 추가
|
||||
|
||||
**커밋**: `a7973bb` | **유형**: feat (기존 API 확장)
|
||||
|
||||
### 배경
|
||||
상품권 대시보드에서 접대비로 전환된 건수/금액을 별도로 표시해야 함.
|
||||
|
||||
### 구현 내용
|
||||
- `expense_accounts` 테이블에서 접대비(상품권) 건수/금액 조회
|
||||
- `entertainment_count`, `entertainment_amount` 응답 필드 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/LoanService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `🔧 수정` [receivables] 상위 거래처 집계 soft delete 제외
|
||||
|
||||
**커밋**: `be9c1ba` | **유형**: fix (버그 수정)
|
||||
|
||||
### 배경
|
||||
매출채권 상위 거래처 집계 쿼리에서 soft delete된 레코드가 포함되어 금액이 부풀려지는 이슈.
|
||||
|
||||
### 구현 내용
|
||||
- orders, deposits, bills 서브쿼리에 `whereNull('deleted_at')` 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/ReceivablesService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 4. `🆕 신규` [finance] 계정과목 및 일반전표 API 추가
|
||||
|
||||
**커밋**: `12d172e` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
회계 시스템의 핵심인 계정과목 관리 및 일반전표(입금/출금/수동전표 통합 목록) API 신규 구현.
|
||||
|
||||
### 구현 내용
|
||||
- `AccountCode` 모델/서비스/컨트롤러 — 계정과목 CRUD
|
||||
- `JournalEntry`, `JournalEntryLine` 모델 — 전표/전표 분개 모델
|
||||
- `GeneralJournalEntryService` — 입금/출금/수동전표 UNION 통합 목록, 수동전표 CRUD
|
||||
- `GeneralJournalEntryController` + FormRequest 검증 클래스
|
||||
- finance 라우트 등록, i18n 메시지 키 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 신규 생성 |
|
||||
| `app/Http/Controllers/Api/V1/GeneralJournalEntryController.php` | 신규 생성 |
|
||||
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 신규 생성 |
|
||||
| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | 신규 생성 |
|
||||
| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | 신규 생성 |
|
||||
| `app/Models/Tenants/AccountCode.php` | 신규 생성 |
|
||||
| `app/Models/Tenants/JournalEntry.php` | 신규 생성 |
|
||||
| `app/Models/Tenants/JournalEntryLine.php` | 신규 생성 |
|
||||
| `app/Services/AccountCodeService.php` | 신규 생성 |
|
||||
| `app/Services/GeneralJournalEntryService.php` | 신규 생성 |
|
||||
| `lang/ko/error.php` | 수정 |
|
||||
| `lang/ko/message.php` | 수정 |
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 5. `🔧 수정` [finance] 일반전표 source 필드 및 페이지네이션 수정
|
||||
|
||||
**커밋**: `816c25a` | **유형**: fix (신규 기능 후속 수정)
|
||||
|
||||
### 배경
|
||||
입금/출금 조회 시 source가 CASE WHEN으로 불필요하게 분기되었고, 페이지네이션 응답 구조가 프론트엔드 기대와 불일치.
|
||||
|
||||
### 구현 내용
|
||||
- deposits/withdrawals 조회 시 source를 항상 `'linked'`로 고정
|
||||
- 페이지네이션 meta 래핑 제거 → 플랫 구조로 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/GeneralJournalEntryService.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 6. `🆕 신규` [menu] 즐겨찾기 테이블 마이그레이션
|
||||
|
||||
**커밋**: `a67c5d9` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
사용자별 메뉴 즐겨찾기 기능을 위한 데이터 테이블 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `menu_favorites` 테이블 — tenant_id, user_id, menu_id, sort_order
|
||||
- unique 제약: (tenant_id, user_id, menu_id)
|
||||
- FK cascade delete: users, menus
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_06_143037_create_menu_favorites_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. `🔧 수정` [departments] options JSON 컬럼 추가
|
||||
|
||||
**커밋**: `56e7164` | **유형**: feat (기존 테이블 확장)
|
||||
|
||||
### 배경
|
||||
조직도 숨기기 등 부서별 확장 속성을 저장할 JSON 컬럼 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `departments` 테이블에 `options` JSON 컬럼 추가
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/..._add_options_to_departments_table.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 8. `🆕 신규` [approval] 결재양식 마이그레이션 (6건)
|
||||
|
||||
**커밋**: `58fedb0`, `eb28b57`, `c5a0115`, `9d4143a`, `449fce1`, `96def0d` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
전자결재 양식 확대 — 사용인감계, 사직서, 위임장, 이사회의사록, 견적서, 공문서 양식 추가.
|
||||
|
||||
### 구현 내용
|
||||
- `seal_usage` — 사용인감계 양식
|
||||
- `resignation` — 사직서 양식
|
||||
- `delegation` — 위임장 양식
|
||||
- `board_minutes` — 이사회의사록 양식
|
||||
- `quotation` — 견적서 양식
|
||||
- `official_letter` — 공문서 양식
|
||||
- 전체 테넌트에 자동 등록
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_06_100000_add_resignation_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_06_210000_add_seal_usage_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_06_230000_add_delegation_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_06_233000_add_board_minutes_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_06_235000_add_quotation_form.php` | 신규 생성 |
|
||||
| `database/migrations/2026_03_07_000000_add_official_letter_form.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 9. `🆕 신규` [database] 경조사비 관리 테이블 + 메뉴 추가
|
||||
|
||||
**커밋**: `0ea5fa5`, `22160e5` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
거래처 경조사비 관리대장 기능 신규 도입. 데이터 테이블 및 사이드바 메뉴 추가 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `condolence_expenses` 테이블 — 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조), 부조금, 선물, 총금액
|
||||
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가 (중복 방지)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/..._create_condolence_expenses_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._add_condolence_expenses_menu.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 10. `🆕 신규` [문서스냅샷] rendered_html 저장 지원 + Lazy Snapshot API
|
||||
|
||||
**커밋**: `293330c`, `5ebf940`, `c5d5b5d` | **유형**: feat + fix
|
||||
|
||||
### 배경
|
||||
문서의 렌더링된 HTML을 스냅샷으로 저장하여 PDF 변환/인쇄 등에 활용. 편집 권한 없이도 스냅샷 갱신 가능한 Lazy Snapshot API 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `Document` 모델 $fillable에 `rendered_html` 추가 (🔧)
|
||||
- `DocumentService` create/update에서 rendered_html 저장 (🔧)
|
||||
- Store/Update/UpsertRequest에 `rendered_html` 검증 추가 (🔧)
|
||||
- `WorkOrderService` 검사문서/작업일지 생성 시 rendered_html 전달 (🔧)
|
||||
- `PATCH /documents/{id}/snapshot` — canEdit 체크 없이 rendered_html만 업데이트 (🆕)
|
||||
- `resolveInspectionDocument()`에 `snapshot_document_id` 반환 (🆕)
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Documents/Document.php` | 수정 |
|
||||
| `app/Services/DocumentService.php` | 수정 |
|
||||
| `app/Services/WorkOrderService.php` | 수정 |
|
||||
| `app/Http/Requests/Document/StoreRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Document/UpdateRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Document/UpsertRequest.php` | 수정 |
|
||||
| `app/Http/Controllers/Api/V1/Documents/DocumentController.php` | 수정 |
|
||||
| `routes/api/v1/documents.php` | 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 11. `🔧 수정` [품질관리] order_ids 영속성 + location 데이터 저장
|
||||
|
||||
**커밋**: `f2eede6` | **유형**: feat (기존 API 확장)
|
||||
|
||||
### 배경
|
||||
품질관리서에 수주 연결 및 개소별 검사 데이터(시공규격, 변경사유, 검사결과)를 저장해야 함.
|
||||
|
||||
### 구현 내용
|
||||
- StoreRequest/UpdateRequest에 `order_ids`, `locations` 검증 추가
|
||||
- `QualityDocumentLocation`에 `inspection_data`(JSON) fillable/cast 추가
|
||||
- store()에 `syncOrders` 연동, update()에 `syncOrders` + `updateLocations` 연동
|
||||
- `inspection_data` 컬럼 추가 마이그레이션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 수정 |
|
||||
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 수정 |
|
||||
| `app/Models/Qualitys/QualityDocumentLocation.php` | 수정 |
|
||||
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
|
||||
| `database/migrations/..._inspection_data_to_quality_document_locations.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 12. `🆕 신규` 제품검사 요청서 Document(EAV) 자동생성 및 동기화
|
||||
|
||||
**커밋**: `2231c9a` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
품질관리서 생성/수정/수주연결 시 제품검사 요청서 Document를 EAV 방식으로 자동 생성하고 동기화해야 함.
|
||||
|
||||
### 구현 내용
|
||||
- `document_template_sections`에 `description` 컬럼 추가
|
||||
- `QualityDocumentService`에 `syncRequestDocument()` 메서드 추가
|
||||
- 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
|
||||
- `rendered_html` 초기화 (데이터 변경 시 재캡처 트리거)
|
||||
- `transformToFrontend`에 `request_document_id` 포함
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Models/Documents/DocumentTemplateSection.php` | 수정 |
|
||||
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
|
||||
| `database/migrations/..._add_description_to_document_template_sections.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 13. `⚙️ 설정` [API] logging, docs, seeder 등 부수 정리
|
||||
|
||||
**커밋**: `ff85530` | **유형**: chore
|
||||
|
||||
### 배경
|
||||
여러 파일의 경로, 설정, 문서 등 소소한 정리 작업.
|
||||
|
||||
### 구현 내용
|
||||
- `LOGICAL_RELATIONSHIPS.md` 보완 (최신 모델 관계 반영)
|
||||
- `Legacy5130Calculator` 수정
|
||||
- `logging.php` 설정 추가
|
||||
- `KyungdongItemSeeder` 수정
|
||||
- docs 문서 경로 수정
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `LOGICAL_RELATIONSHIPS.md` | 수정 |
|
||||
| `app/Helpers/Legacy5130Calculator.php` | 수정 |
|
||||
| `config/logging.php` | 수정 |
|
||||
| `database/seeders/Kyungdong/KyungdongItemSeeder.php` | 수정 |
|
||||
| `docs/INDEX.md` | 수정 |
|
||||
40
claudedocs/backend/2026-03-07_구현내역.md
Normal file
40
claudedocs/backend/2026-03-07_구현내역.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 2026-03-07 (토) 백엔드 구현 내역
|
||||
|
||||
## 1. `🆕 신규` [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션
|
||||
|
||||
**커밋**: `ad93743` | **유형**: feat
|
||||
|
||||
### 배경
|
||||
근로기준법에 따른 연차사용촉진 통지서(1차/2차) 양식을 전자결재 시스템에 등록 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `leave_promotion_1st` — 연차사용촉진 통지서 (1차) 양식, hr 카테고리
|
||||
- `leave_promotion_2nd` — 연차사용촉진 통지서 (2차) 양식, hr 카테고리
|
||||
- 전체 테넌트에 자동 등록
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `database/migrations/2026_03_07_100000_add_leave_promotion_forms.php` | 신규 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 2. `🔧 수정` [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
|
||||
|
||||
**커밋**: `3ac64d5` | **유형**: feat (기존 API 확장)
|
||||
|
||||
### 배경
|
||||
품질관리서 작성 시 수주 선택 API에 거래처/품목 필터가 없고, 개소별 상세 데이터 부족. 검사 상태 판별 로직도 개선 필요.
|
||||
|
||||
### 구현 내용
|
||||
- `availableOrders` — `client_id`/`item_id` 필터 파라미터 지원
|
||||
- 응답에 `client_id`, `client_name`, `item_id`, `item_name`, `locations`(개소 상세) 추가
|
||||
- `show` — 개소별 데이터에 거래처/모델 정보 포함
|
||||
- `DocumentService` — `fqcStatus`를 rootNodes 기반으로 변경
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Services/QualityDocumentService.php` | 수정 |
|
||||
| `app/Services/DocumentService.php` | 수정 |
|
||||
| `LOGICAL_RELATIONSHIPS.md` | 수정 |
|
||||
47
claudedocs/backend/2026-03-08_구현내역.md
Normal file
47
claudedocs/backend/2026-03-08_구현내역.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 2026-03-08 (일) 백엔드 구현 내역
|
||||
|
||||
## 1. `🔧 수정` [finance] 계정과목 확장 및 전표 연동 시스템 구현
|
||||
|
||||
**커밋**: `0044779` | **유형**: feat (3/6 신규 기능 대규모 확장)
|
||||
|
||||
### 배경
|
||||
3/6에 추가한 계정과목/일반전표 기본 API를 확장하여 기본 계정과목 시딩, 전표 자동 연동(카드거래/세금계산서), 계정과목 업데이트 기능 구현.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
#### 계정과목 확장 (🔧 기존 확장)
|
||||
- `AccountCode` 모델 확장 — 관계, 스코프, 헬퍼 추가
|
||||
- `AccountCodeService` 확장 — 업데이트, 트리 조회, 기본 계정과목 시딩 로직
|
||||
- `UpdateAccountSubjectRequest` 신규 — 업데이트 검증 규칙
|
||||
- `StoreAccountSubjectRequest` — 추가 검증 규칙 보강
|
||||
|
||||
#### 전표 자동 연동 (🆕 신규)
|
||||
- `JournalSyncService` 신규 — 카드거래/세금계산서 → 전표 자동 생성 서비스
|
||||
- `SyncsExpenseAccounts` 트레이트 — 경비계정 동기화 공통 로직
|
||||
- `CardTransactionController` 확장 — 전표 연동 엔드포인트 추가
|
||||
- `TaxInvoiceController` 확장 — 전표 연동 엔드포인트 추가
|
||||
|
||||
#### 데이터베이스 (🆕 신규)
|
||||
- `expense_accounts` 테이블에 전표 연결 컬럼 마이그레이션 (journal_entry_id 등)
|
||||
- `account_codes` 테이블 확장 마이그레이션 (추가 속성 컬럼)
|
||||
- 전체 테넌트 기본 계정과목 시딩 마이그레이션
|
||||
|
||||
### 변경 파일
|
||||
| 파일 | 작업 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 수정 |
|
||||
| `app/Http/Controllers/Api/V1/CardTransactionController.php` | 수정 (대규모) |
|
||||
| `app/Http/Controllers/Api/V1/TaxInvoiceController.php` | 수정 (대규모) |
|
||||
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 수정 |
|
||||
| `app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php` | 신규 생성 |
|
||||
| `app/Models/Tenants/AccountCode.php` | 수정 |
|
||||
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
|
||||
| `app/Models/Tenants/JournalEntry.php` | 수정 |
|
||||
| `app/Services/AccountCodeService.php` | 수정 (대규모) |
|
||||
| `app/Services/GeneralJournalEntryService.php` | 수정 |
|
||||
| `app/Services/JournalSyncService.php` | 신규 생성 |
|
||||
| `app/Traits/SyncsExpenseAccounts.php` | 신규 생성 |
|
||||
| `database/migrations/..._add_journal_link_to_expense_accounts_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._enhance_account_codes_table.php` | 신규 생성 |
|
||||
| `database/migrations/..._seed_default_account_codes_for_all_tenants.php` | 신규 생성 |
|
||||
| `routes/api/v1/finance.php` | 수정 |
|
||||
72
claudedocs/backend/_index.md
Normal file
72
claudedocs/backend/_index.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# SAM API 백엔드 구현 내역서
|
||||
|
||||
## 2026년 3월 1주차 (3/2 ~ 3/8)
|
||||
|
||||
총 **83개 커밋**, 7일간 구현 내역
|
||||
|
||||
### 태그 범례
|
||||
| 태그 | 의미 |
|
||||
|------|------|
|
||||
| `🆕 신규` | 새로운 기능/API/테이블 생성 |
|
||||
| `🔧 수정` | 기존 기능 버그 수정, 확장, 보완 |
|
||||
| `🔄 리팩토링` | 기능 변경 없이 코드 구조 개선 |
|
||||
| `⚙️ 설정` | 환경 설정, 인프라, 문서 정리 |
|
||||
|
||||
### 날짜별 문서
|
||||
|
||||
| 날짜 | 파일 | 주요 작업 | 🆕 | 🔧 | 🔄 | ⚙️ |
|
||||
|------|------|-----------|-----|-----|-----|-----|
|
||||
| 3/2 (월) | [2026-03-02_구현내역.md](./2026-03-02_구현내역.md) | 로드맵 테이블, AI 견적 엔진 | 2 | - | - | - |
|
||||
| 3/3 (화) | [2026-03-03_구현내역.md](./2026-03-03_구현내역.md) | Gemini 업그레이드, 배포 수정, HR 확장, 자재투입 개선 | - | 7 | - | 1 |
|
||||
| 3/4 (수) | [2026-03-04_구현내역.md](./2026-03-04_구현내역.md) | 바로빌 연동, 리스크 대시보드, 지출결의서, 배차 시스템 | 6 | 9 | - | - |
|
||||
| 3/5 (목) | [2026-03-05_구현내역.md](./2026-03-05_구현내역.md) | CEO 대시보드, 어음 V8, 상품권 접대비, 생산지시, 품질관리 | 7 | 7 | 2 | 1 |
|
||||
| 3/6 (금) | [2026-03-06_구현내역.md](./2026-03-06_구현내역.md) | 계정과목/일반전표, 문서 스냅샷, 결재양식 6종, 경조사비 | 7 | 5 | - | 1 |
|
||||
| 3/7 (토) | [2026-03-07_구현내역.md](./2026-03-07_구현내역.md) | 연차촉진 통지서, 품질검사 필터링 | 1 | 1 | - | - |
|
||||
| 3/8 (일) | [2026-03-08_구현내역.md](./2026-03-08_구현내역.md) | 계정과목 확장, 전표 연동 시스템 | - | 1 | - | - |
|
||||
| **합계** | | | **23** | **30** | **2** | **3** |
|
||||
|
||||
### 도메인별 주요 기능
|
||||
|
||||
#### 재무/회계
|
||||
- 🆕 계정과목 및 일반전표 API 신규 구축
|
||||
- 🆕 전표 자동 연동 (카드거래/세금계산서)
|
||||
- 🆕 접대비 상세 조회 API + 리스크 감지
|
||||
- 🆕 부가세 상세 조회 API
|
||||
- 🆕 경조사비 관리 테이블
|
||||
- 🆕 바로빌 연동 API
|
||||
- 🔧 접대비/복리후생비 리스크 감지형 대시보드 전환
|
||||
- 🔧 매출채권 상세 대시보드 개선
|
||||
- 🔧 가지급금 카테고리 분류 (카드/경조사/상품권/접대비)
|
||||
- 🔧 상품권 접대비 자동 연동
|
||||
- 🔧 어음 V8 확장 필드 (54개)
|
||||
|
||||
#### 생산/품질
|
||||
- 🆕 생산지시 전용 API (목록/통계/상세)
|
||||
- 🆕 품질관리서 CRUD API (14개 엔드포인트)
|
||||
- 🆕 실적신고 관리 API (6개 엔드포인트)
|
||||
- 🆕 제품검사 요청서 EAV 자동생성
|
||||
- 🆕 보조 공정(재고생산) 분리
|
||||
- 🔧 절곡 검사 데이터 복제/EAV 변환
|
||||
- 🔧 자재투입 bom_group_key/replace 모드
|
||||
|
||||
#### 전자결재
|
||||
- 🆕 Document ↔ Approval 브릿지 연동
|
||||
- 🆕 결재양식 11종 추가 (지출결의서, 근태신청, 사유서, 재직증명서 등)
|
||||
- 🔧 drafter_read_at, resubmit_count, rejection_history 컬럼
|
||||
|
||||
#### 대시보드/리포트
|
||||
- 🆕 CEO 대시보드 6개 섹션 API
|
||||
- 🆕 일일보고서 엑셀 내보내기
|
||||
- 🔧 자금현황 카드 필드
|
||||
|
||||
#### 출고/배차
|
||||
- 🆕 배차정보 다중 행 시스템
|
||||
- 🆕 배차차량 관리 API
|
||||
|
||||
#### 인프라/기타
|
||||
- ⚙️ Gemini 2.5-flash 업그레이드
|
||||
- 🔧 .env 권한 640 보장 (배포)
|
||||
- ⚙️ Slack 알림 채널 분리
|
||||
- 🆕 문서 rendered_html 스냅샷 API
|
||||
- 🆕 메뉴 즐겨찾기 테이블
|
||||
- 🔧 주소 필드 500자 확장
|
||||
213
claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md
Normal file
213
claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# CEO 대시보드 수정계획서 (최종)
|
||||
|
||||
**작성일**: 2026-03-09
|
||||
**기반 문서**: `[QA-2026-03-09] ceo-dashboard-ui-verification.md`
|
||||
**검증 수준**: 화면(Chrome DevTools) + 프론트엔드 코드 + 백엔드 코드 + 실제 데이터 전부 확인 완료
|
||||
|
||||
---
|
||||
|
||||
## 최종 이슈 요약
|
||||
|
||||
| 분류 | 건수 | 내용 |
|
||||
|------|------|------|
|
||||
| 🟡 백엔드 개선 | 1건 | 현황판/채권추심 sub_label 필드 추가 |
|
||||
| 🟢 프론트엔드 개선 | 2건 | 더미값 제거, 매입 라벨 명확화 |
|
||||
| ✅ 수정 불필요 (오진 정정) | 8건 | 아래 상세 표 참조 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 수정 필요 항목
|
||||
|
||||
### B3. 현황판/채권추심 거래처 sub_label 필드 추가 🟡
|
||||
|
||||
**현상**: 프론트엔드에서 하드코딩된 더미 거래처명 사용 중
|
||||
|
||||
| 위치 | 더미값 | TODO 주석 |
|
||||
|------|--------|----------|
|
||||
| `transformers/status-issue.ts:20-29` | "주식회사 부산화학 외" 등 | ✅ 있음 (line 18) |
|
||||
| `transformers/receivable.ts:96-103` | "(주)부산화학 외" 등 | ✅ 있음 (line 96) |
|
||||
|
||||
**백엔드 수정 내용**:
|
||||
|
||||
1. **`StatusBoardService.php`** — 각 항목 응답에 `sub_label` 필드 추가
|
||||
- `getBadDebtStatus()` (line 68-83): 최다 금액 거래처 실제 이름 조회
|
||||
- `getNewClientStatus()`: 최근 등록 업체명 조회
|
||||
- 기타 항목도 해당 시 sub_label 제공
|
||||
|
||||
2. **`BadDebtService.php`** — `summary()` 응답에 per-card 거래처 정보 추가
|
||||
- `top_client_name`: 누적 악성채권 최다 금액 거래처명
|
||||
- 카드별 `sub_label`: 해당 카테고리 최다 금액 거래처명 + 건수
|
||||
|
||||
**프론트엔드 후속 작업**: B3 완료 후 → F1 (더미값 제거)
|
||||
|
||||
---
|
||||
|
||||
### F1. 더미 거래처명 제거 (B3 완료 후) 🟢
|
||||
|
||||
**대상 파일**:
|
||||
- `src/lib/api/dashboard/transformers/status-issue.ts`
|
||||
- Line 20-29: `STATUS_BOARD_FALLBACK_SUB_LABELS` 상수 제거
|
||||
- Line 35-53: `buildStatusSubLabel()` → API `sub_label` 직접 사용
|
||||
|
||||
- `src/lib/api/dashboard/transformers/receivable.ts`
|
||||
- Line 98-103: `DEBT_COLLECTION_FALLBACK_SUB_LABELS` 상수 제거
|
||||
- Line 109-121: `buildDebtSubLabel()` → API 제공 값 사용
|
||||
|
||||
---
|
||||
|
||||
### F3. 매입 섹션 "당월" 컨텍스트 명확화 🟢
|
||||
|
||||
**현상**:
|
||||
- 섹션 subtitle: "당월 매입 실적" + Badge: "당월"
|
||||
- 카드 라벨: "누적 매입" (line 65 — 이것 자체는 정확)
|
||||
- **실제 데이터**: `cumulative_purchase` = 연간 누적 (2026-01-01 ~ 오늘, Feb 포함)
|
||||
- 일별 매입 내역: 2026-02-27 거래 표시 → "당월 매입 내역" 제목 하에 전월 데이터 포함
|
||||
|
||||
**코드 확인**:
|
||||
- `PurchaseStatusSection.tsx:50` — `subtitle="당월 매입 실적"`
|
||||
- `PurchaseStatusSection.tsx:53` — `<Badge>당월</Badge>`
|
||||
- `PurchaseStatusSection.tsx:65` — `<span>누적 매입</span>`
|
||||
- `DashboardCeoService.php:175-180` — `whereYear('purchase_date', $year)` = 연간 누적
|
||||
|
||||
**수정 방향**:
|
||||
- subtitle: "당월 매입 실적" → "매입 실적" 또는 "연간 매입 현황"
|
||||
- Badge: "당월" → 제거 또는 "YTD"로 변경
|
||||
- 하단 카드 title: "당월 매입 내역" → "최근 매입 내역"
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정 불필요 항목 (최종 정리)
|
||||
|
||||
### 1차 QA → 2차 검증 → 최종 검토로 순차 정정된 이슈들
|
||||
|
||||
| # | 이전 보고 | 최종 검증 결과 | 검증 근거 |
|
||||
|---|----------|-------------|----------|
|
||||
| ~~C1~~ | 5개 섹션 본문 미렌더링 | **LazySection 정상** — 스크롤 시 로드 | `LazySection.tsx` IntersectionObserver + DOM 확인 |
|
||||
| ~~C2~~ | 매출 금액 10배 차이 | **NavBar=누적, 본문=당월 구분 표시** | `SalesStatusSection.tsx:63` "누적 매출" 라벨 확인 |
|
||||
| ~~C3~~ | 발행어음 데이터 불일치 | **다른 테이블 (설계 의도)** | `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) |
|
||||
| ~~C4~~ | 채권추심 건수 불일치 | **다른 산출 기준 (설계 의도)** | StatusBoard=레코드 7건(status=collecting) vs BadDebt=거래처 5곳(distinct client_id) |
|
||||
| ~~I2~~ | 카드 월별 합계 0원 버그 | **정상 — 해당 월 데이터 없음** | 카드 거래 20건 모두 2025-01~2026-01-28, 2/3월 거래 0건 |
|
||||
| ~~I3~~ | 미수금 산출 기준 차이 | **다른 산출 방식 (설계 의도)** | 화면 확인 |
|
||||
| ~~M1~~ | 가지급금 카드 금액 차이 | **다른 기준 (설계 의도)** | 가지급금 전환 기준 vs 카드 사용 총액 |
|
||||
| ~~발주~~ | 현황판 "발주" 미표시 | **의도적 숨김** | `STATUS_BOARD_HIDDEN_ITEMS.has('purchases')` (2026-03-03 비활성화) |
|
||||
|
||||
### 상세 정정 사항
|
||||
|
||||
#### ~~B1: 카드 월별 합계 0원~~ — SQL 버그 아님 ✅
|
||||
|
||||
**이전 판단**: `DB::raw('DATE(COALESCE(used_at, withdrawal_date))')` SQL 버그
|
||||
|
||||
**최종 판단**: **데이터가 없어서 0이 정상**
|
||||
|
||||
```
|
||||
카드 거래 20건 날짜 분포:
|
||||
- 가장 오래된 거래: 2025-01-12 (used_at=NULL, withdrawal_date만)
|
||||
- 가장 최근 거래: 2026-01-28 (used_at='2026-01-28')
|
||||
- 2026-02 거래: 0건
|
||||
- 2026-03 거래: 0건
|
||||
→ current_month_total=0, previous_month_total=0 모두 정확
|
||||
```
|
||||
|
||||
**QA 오진 원인**: 카드사용내역 리스트 페이지에서 "15건 표시"를 보고 당월/전월에도 데이터가 있을 것으로 추정했으나, 리스트는 전체 기간 데이터를 표시.
|
||||
|
||||
**참고**: `summary()` 메서드의 SQL 패턴(`DB::raw COALESCE`)이 `index()` 메서드(`whereDate + orWhere`)와 다르지만, 현재 데이터에서는 정상 작동 확인. 향후 코드 일관성을 위해 패턴 통일은 권장하나, 긴급하지 않음.
|
||||
|
||||
#### ~~B2: 현황판 vs 채권추심 건수~~ — 설계 의도 ✅
|
||||
|
||||
**이전 판단**: 건수 통일 필요
|
||||
|
||||
**최종 판단**: **의도적으로 다른 관점 제공**
|
||||
|
||||
| API | 쿼리 | 의미 |
|
||||
|-----|------|------|
|
||||
| `StatusBoardService.php:72` | `where('status', 'collecting')->count()` | "지금 추심중인 건" = 7건 |
|
||||
| `BadDebtService.php:107-111` | `distinct('client_id')->count('client_id')` | "악성채권이 있는 거래처 수" = 5곳 |
|
||||
|
||||
현황판은 "현재 추심 진행 중인 건수"를, 채권추심 본문은 "악성채권 보유 거래처 수"를 보여주는 것으로, 각각 다른 관점의 지표임. 사용자가 혼동할 수 있으나, 정보 제공 목적이 다름.
|
||||
|
||||
#### ~~B5: 매입 "당월" 기간~~ — 백엔드 정상, 프론트 라벨 이슈 ✅
|
||||
|
||||
`DashboardCeoService.php:175-180`의 `cumulative_purchase`는 `whereYear(2026)` = 연간 누적으로 정확히 산출. "당월" 라벨은 프론트엔드 이슈 → F3으로 처리.
|
||||
|
||||
---
|
||||
|
||||
## 3. 수정 우선순위
|
||||
|
||||
| 순위 | 이슈 | 영역 | 난이도 | 비고 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | B3: sub_label 필드 추가 | 백엔드 | 중 | 거래처 조회 쿼리 추가 |
|
||||
| 2 | F1: 더미 거래처명 제거 | 프론트 | 하 | B3 완료 후 상수/함수 제거 |
|
||||
| 3 | F3: 매입 섹션 라벨 명확화 | 프론트 | 하 | 텍스트만 변경 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 수정 후 재검수 계획
|
||||
|
||||
| 단계 | 항목 | 검증 방법 |
|
||||
|------|------|----------|
|
||||
| 1 | B3+F1 수정 후: 거래처명 | 현황판/채권추심에 실제 거래처명 표시 확인 |
|
||||
| 2 | F3 수정 후: 매입 라벨 | "당월" 대신 적절한 기간 표현 확인 |
|
||||
| 3 | 전체: 상세 모달 | 각 섹션 모달 열기/닫기/날짜필터 동작 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 부록: 관련 파일 위치
|
||||
|
||||
### 백엔드 (sam-api)
|
||||
| 파일 | 이슈 | 상태 |
|
||||
|------|------|------|
|
||||
| `app/Services/StatusBoardService.php:68-83` | B3 (sub_label 추가) | 수정 필요 |
|
||||
| `app/Services/BadDebtService.php:107-111` | B3 (per-card 거래처) | 수정 필요 |
|
||||
| `app/Services/CardTransactionService.php:109-153` | ~~B1~~ | ✅ 정상 (데이터 없음이 원인) |
|
||||
| `app/Services/ExpectedExpenseService.php:241-301` | ~~B4~~ | ✅ 정상 (다른 테이블, 설계 의도) |
|
||||
| `app/Services/DashboardCeoService.php:166-234` | ~~B5~~ | ✅ 정상 (프론트 라벨만 수정) |
|
||||
|
||||
### 프론트엔드 (sam-react-prod)
|
||||
| 파일 | 이슈 | 상태 |
|
||||
|------|------|------|
|
||||
| `src/lib/api/dashboard/transformers/status-issue.ts:18-53` | F1 (더미 sub_label) | 수정 필요 (B3 후) |
|
||||
| `src/lib/api/dashboard/transformers/receivable.ts:96-121` | F1 (더미 sub_label) | 수정 필요 (B3 후) |
|
||||
| `src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx:50-53,161` | F3 (라벨) | 수정 필요 |
|
||||
| `src/components/business/CEODashboard/LazySection.tsx` | ~~C1~~ | ✅ 정상 |
|
||||
| `src/components/business/CEODashboard/sections/SalesStatusSection.tsx:63` | ~~F2~~ | ✅ 정상 ("누적 매출" 명확) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 하단 섹션 추가 검증 결과 (3차)
|
||||
|
||||
### 생산/출고/시공/근태 4개 섹션 소스 페이지 대조 + 코드 검증
|
||||
|
||||
| 섹션 | 대시보드 | 소스 페이지 | API 엔드포인트 | 결론 |
|
||||
|------|---------|-----------|-------------|------|
|
||||
| 생산 현황 | 0공정 | 작업지시 39건 (모두 2월/작업대기) | `dashboard/production/summary` | ✅ 정확 (오늘 예정 없음) |
|
||||
| 출고 현황 | 0건/0원 | 당일출고 0건, 전체 8건 (12~1월) | (생산과 동일 API) | ✅ 정확 (당월 건 없음) |
|
||||
| 시공 현황 | 0건 | 시공진행 7/완료 4 (**Mock 데이터**) | `dashboard/construction/summary` | ✅ 비교불가 (소스=Mock) |
|
||||
| 근태 현황 | 0명 전부 | 미출근 55명/출근 0명 | `dashboard/attendance/summary` | ✅ 설계 차이 (기록 vs 명부) |
|
||||
|
||||
### 참고 사항 (향후 개선 검토)
|
||||
|
||||
1. **시공 현황 — NULL end_date 처리** (`DashboardCeoService.php:555-567`)
|
||||
- 현재 쿼리: `contract_end_date >= $monthEnd` 조건에서 NULL 제외됨
|
||||
- 실제 계약 데이터 투입 시, 진행 중(`end_date IS NULL`) 계약이 대시보드에 미표시될 수 있음
|
||||
- 권장: `orWhere(fn($q) => $q->where('start_date', '<=', $monthEnd)->whereNull('end_date'))` 조건 추가 검토
|
||||
|
||||
2. **시공관리 페이지 — API 미연동** (`construction/management/actions.ts`)
|
||||
- `TODO: 실제 API 연동 시 구현` 주석 → 현재 전체가 Mock 데이터
|
||||
- 대시보드 시공 섹션은 실제 `contracts` 테이블 조회 → 소스 페이지와의 정합성 검증은 API 연동 완료 후 재실시
|
||||
|
||||
3. **근태 대시보드 — "미출근" 미표시**
|
||||
- 대시보드는 `attendances` 테이블 레코드 기반 → 출근 기록 없으면 모두 0
|
||||
- 근태관리 페이지는 사원 명부 기반 → "미출근 55명" 표시
|
||||
- CEO 관점에서 "미출근" 정보가 필요한지는 비즈니스 결정 사항
|
||||
|
||||
---
|
||||
|
||||
## 검증 이력
|
||||
|
||||
| 단계 | 내용 | 결과 |
|
||||
|------|------|------|
|
||||
| 1차 QA | 화면 검수 (18개 섹션) | Critical 4건 + Important 3건 보고 |
|
||||
| 2차 검증 | LazySection + API 응답 확인 | Critical 4건 → 전부 정정 (버그 아님) |
|
||||
| 3차 검토 | 백엔드 코드 + 실제 DB 데이터 확인 | Important 3건 중 1건(카드 0원) 추가 정정 |
|
||||
| 4차 추가 검증 | 하단 4개 섹션 소스 대조 + 코드 검증 | 4건 모두 정상 (참고 3건 기록) |
|
||||
| **최종 결론** | **실제 수정 필요: 3건** (백엔드 1 + 프론트 2) | 나머지 모두 수정 불필요 확인 |
|
||||
@@ -0,0 +1,252 @@
|
||||
# CEO 대시보드 UI 검수 결과 (2차 검증 포함)
|
||||
|
||||
**작성일**: 2026-03-09
|
||||
**목적**: 대시보드 전체 18개 섹션의 API 데이터 정합성 및 연동 검증
|
||||
**방법**: 화면 검수 (Chrome DevTools MCP로 실제 화면 조작 + DOM 검증)
|
||||
|
||||
---
|
||||
|
||||
## 검수 범위 요약
|
||||
|
||||
| 구분 | 수량 | 비고 |
|
||||
|------|------|------|
|
||||
| 대시보드 카드 섹션 | 18개 | SummaryNavBar 기준 |
|
||||
| 본문 렌더링 | **18개 전부** | LazySection으로 스크롤 시 로드 (2차 검증) |
|
||||
| 상세 모달 | 10개 | 날짜필터 포함 |
|
||||
| Mock 섹션 (제외) | 2개 | 일별 매출/매입 내역 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 카드 수치 표출 확인 ✅ 완료
|
||||
|
||||
대시보드 로드 후 각 카드에 표시된 수치를 기록.
|
||||
|
||||
| # | 섹션 | SummaryNavBar 값 | 본문 카드 | 확인 |
|
||||
|---|------|-----------------|----------|------|
|
||||
| 1 | 오늘의 이슈 | 3건 | ✅ 렌더링 | - [x] |
|
||||
| 2 | 자금현황 | 0원 | ✅ 렌더링 (미수금 9억4,697만 / 미지급금 1억5,944만) | - [x] |
|
||||
| 3 | 현황판 | 7항목 | ✅ 렌더링 (수주0/채권추심7/안전재고833/세금신고-/신규업체45/연차0/결재1) | - [x] |
|
||||
| 4 | 당월 예상 지출 | 1억 | ✅ 렌더링 (매입0/카드0/발행어음1억) | - [x] |
|
||||
| 5 | 가지급금 현황 | 1,150만 | ✅ 렌더링 (카드1,150만/경조사0/상품권0/접대비0) | - [x] |
|
||||
| 6 | 접대비 현황 | 0원 | ✅ 렌더링 (리스크 항목 4개 모두 0) | - [x] |
|
||||
| 7 | 복리후생비 현황 | 40만 | ✅ 렌더링 (리스크 항목: 사적사용20만1건/특정인편중20만1건) | - [x] |
|
||||
| 8 | 미수금 현황 | 9.4억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||
| 9 | 채권추심 현황 | 1.2억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||
| 10 | 부가세 현황 | 0원 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||
| 11 | 캘린더 | 26일정 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||
| 12 | 매출 현황 | 1.1억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||
| 13 | 매입 현황 | 165만 | ✅ 렌더링 (당월 누적매입165만 / 미결제165만 / 차트+테이블) | - [x] |
|
||||
| 14 | 생산 현황 | 0공정 | ✅ 렌더링 (작업지시 없음) | - [x] |
|
||||
| 15 | 출고 현황 | 0건 | ✅ 렌더링 (7일이내0 / 30일이내0) | - [x] |
|
||||
| 16 | 미출고 내역 | 6건 | ✅ 렌더링 (6건 상세목록 표시) | - [x] |
|
||||
| 17 | 시공 현황 | 0건 | ✅ 렌더링 (시공진행0/시공완료0) | - [x] |
|
||||
| 18 | 근태 현황 | 0명 | ✅ 렌더링 (출근0/휴가0/지각0/결근0) | - [x] |
|
||||
|
||||
### 2차 검증: LazySection 확인 (1차 QA 오류 정정)
|
||||
|
||||
1차 QA에서 "본문 미렌더링"으로 보고된 5개 섹션(미수금/채권추심/부가세/캘린더/매출)은 실제로는 **LazySection**(IntersectionObserver 기반 lazy loading)으로 정상 작동합니다. 스크롤하여 뷰포트에 진입하면 콘텐츠가 로드됩니다.
|
||||
|
||||
**확인 방법**:
|
||||
- DOM 검사: `[data-section-key]` 18개 전부 존재 확인
|
||||
- 스크롤 후 콘텐츠 확인: 5개 섹션 모두 데이터 정상 렌더링
|
||||
- LazySection.tsx 분석: IntersectionObserver + rootMargin='300px' 패턴
|
||||
|
||||
**스크롤 후 확인된 본문 데이터**:
|
||||
| 섹션 | 본문 주요 수치 | NavBar 값 | 일치 |
|
||||
|------|--------------|----------|------|
|
||||
| 미수금 | 누적 미수금 9억 4,164만 / 미수금 거래처 79건 / 연체 1건 / 악성채권 11건 | 9.4억 | ✅ |
|
||||
| 채권추심 | 누적 악성채권 1억 1,869만 / (주)부산화학 외 4건 | 1.2억 | ✅ |
|
||||
| 부가세 | 매출세액 0원 / 매입세액 0원 / 예상 납부세액 0원 / 미발행 1건 | 0원 | ✅ |
|
||||
| 캘린더 | 2026년 3월 전체 일정 표시 | 26일정 | ✅ |
|
||||
| 매출 | 당월누적 매출 1억 673만 / 달성률 6% / 전년대비 -93.6% / 당월 매출 1,045만 | 1.1억 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 상세 모달 + 날짜필터 검증
|
||||
|
||||
### 2-4. 복리후생비 상세 모달 ✅ (검증 완료)
|
||||
| 테스트 | 방법 | 확인 |
|
||||
|--------|------|------|
|
||||
| 모달 열기 | 카드 클릭 → 요약/차트/테이블 확인 | - [x] 완료 |
|
||||
| 당월 날짜필터 | 당월 → 데이터 있음 (1건 200,000) | - [x] 완료 |
|
||||
| 전월 날짜필터 | 전월 → 데이터 없음 (0건) | - [x] 완료 |
|
||||
|
||||
### 나머지 모달 (Phase 2)
|
||||
> 당월 예상 지출, 가지급금, 접대비 등 나머지 모달은 하단 수정계획에 따라 이슈 수정 후 재검수 예정.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 소스 페이지 ↔ 대시보드 데이터 연동 검증 ✅ 완료
|
||||
|
||||
### 3-1. 복리후생비 (세금계산서 분개) ✅ 검증 완료
|
||||
| 테스트 | 소스 페이지 | 결과 | 확인 |
|
||||
|--------|-----------|------|------|
|
||||
| 분개 추가 | 세금계산서관리 | 대시보드 금액 변동 확인 (51만→31만) | - [x] ✅ |
|
||||
| 계정 변경 | 세금계산서관리 | 대시보드 금액 변동 확인 (51만→40만) | - [x] ✅ |
|
||||
| 날짜필터 | 대시보드 모달 | 전월 변경 → 0건 표시 | - [x] ✅ |
|
||||
|
||||
### 3-2. 미수금 현황 ⚠️ 산출 기준 다름
|
||||
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||
|--------|---------|-----------|------|
|
||||
| 미수금 잔액 | 9억 4,697만 | 미수금현황 합계 미수금 = **음수** (-311,979,400) | ⚠️ 산출 기준 불일치 |
|
||||
|
||||
> 대시보드의 미수금은 자금현황 카드 내 "미수금 잔액"으로 표시. 미수금현황 페이지의 합계 행은 월별 차이금액의 합산으로 음수 표시. 두 페이지의 산출 기준이 완전히 다름.
|
||||
|
||||
### 3-3. 매출 현황 ✅ 정정 (2차 검증)
|
||||
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||
|--------|---------|-----------|------|
|
||||
| 매출 금액 (NavBar) | 1.1억 | cumulative_sales = 106,726,323 (1.07억) | ✅ NavBar는 누적매출 표시 (반올림 1.1억) |
|
||||
| 매출 금액 (본문) | 당월누적 1억 673만 / 당월 1,045만 | 매출관리 당월 매출 = 10,450,000원 | ✅ 본문에서 구분 표시 |
|
||||
|
||||
> **1차 QA 오류 정정**: NavBar "1.1억"은 `cumulative_sales`(누적매출)이며, 본문에서는 "당월누적 매출 1억 673만"과 "당월 매출 1,045만"을 구분 표시. 10배 차이가 아닌 다른 지표 표시.
|
||||
|
||||
### 3-4. 매입 현황 ✅ 일치
|
||||
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||
|--------|---------|-----------|------|
|
||||
| 매입 금액 | 165만 | 매입관리 합계 = **1,650,000원** | ✅ 일치 |
|
||||
|
||||
> 단, "당월" 라벨이지만 데이터는 2026-02-27 것임 (3월 매입 없음). 라벨 정확성 재검토 필요.
|
||||
|
||||
### 3-5. 당월 예상 지출 (발행어음) ⚠️ 소스 확인 필요
|
||||
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||
|--------|---------|-----------|------|
|
||||
| 발행어음 | 1억 | 어음관리 당월 = 수취어음 2건 40,000원 **(발행어음 0건)** | ⚠️ 다른 데이터 소스 |
|
||||
|
||||
> 대시보드의 발행어음 1억은 `expected-expenses` API에서 `by_transaction_type.bill.total = 100,000,000`으로 제공. 어음관리 페이지(`bills` 테이블)와 다른 데이터 소스(`expected_expenses` 테이블) 사용. **최종 확인: 설계 의도** — expected_expenses는 수동 입력된 지출 예측 데이터이며, bills는 실제 발행어음 문서. 두 시스템은 독립적.
|
||||
|
||||
### 3-6. 가지급금 현황 ⚠️ 기준 다름
|
||||
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||
|--------|---------|-----------|------|
|
||||
| 카드 | 1,150만 | 카드사용내역 당월 합계 ≈ 467만 | ⚠️ 기준 다름 (가지급금 전환 기준) |
|
||||
| 상품권 | 0원 | 상품권관리 보유 0건/0원 | ✅ 일치 |
|
||||
|
||||
> ~~카드사용내역 요약(전월/당월/건수)이 모두 0원/0건으로 표시 — API 버그~~
|
||||
> **최종 확인: 버그 아님** — 카드 거래 20건의 날짜 범위가 2025-01~2026-01-28이며, 2026년 2월/3월 거래는 0건. 따라서 전월/당월 합계 0원은 정확한 값.
|
||||
|
||||
### 3-7. 미출고 내역 ✅ 대시보드 내 확인
|
||||
| 테스트 | 대시보드 | 결과 |
|
||||
|--------|---------|------|
|
||||
| 미출고 | 6건 | 대시보드 카드 내 6건 상세목록 표시 (LOT번호, 현장명, 납기일 포함) | ✅ |
|
||||
|
||||
### 3-8. 채권추심 현황 ⚠️ 건수 불일치 + 더미 거래처명
|
||||
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||
|--------|---------|-----------|------|
|
||||
| 금액 | 본문 1억 1,869만 / NavBar 1.2억 | 악성채권 5건 합계 ≈ 1.19억 | ✅ 일치 |
|
||||
| 건수 (현황판) | 7건 | 악성채권관리 = **5건** | ⚠️ status-board API 별도 산출 |
|
||||
| 건수 (채권추심 본문) | 5건 (client_count) | 악성채권관리 = 5건 | ✅ 일치 |
|
||||
| 거래처명 | "(주)부산화학 외 4건" | 실제 거래처 미확인 | ⚠️ **하드코딩 더미값** |
|
||||
|
||||
> **2차 검증 발견**: 채권추심 본문/현황판의 거래처명("부산화학", "삼성테크" 등)은 `DEBT_COLLECTION_FALLBACK_SUB_LABELS`와 `STATUS_BOARD_FALLBACK_SUB_LABELS`에 하드코딩된 **더미값**. 코드에 `// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거` 주석 있음.
|
||||
|
||||
### 3-9. 현황판 "발주" 미표시 ✅ 의도적 숨김 (2차 검증)
|
||||
|
||||
> `STATUS_BOARD_HIDDEN_ITEMS`에 `purchases`가 포함되어 의도적으로 숨김 처리. 사용자 설정에서도 `purchase: false`. 백엔드 path 오류 + 데이터 정합성 이슈 해결 전까지 비활성화 (코드 주석: `[2026-03-03] 비활성화`).
|
||||
|
||||
---
|
||||
|
||||
## 발견된 이슈 요약 (최종 검토 반영)
|
||||
|
||||
### 🔴 Critical → 없음 (1차 이슈 모두 정정)
|
||||
|
||||
1차 QA의 Critical 이슈 4건은 2차 검증에서 모두 재분류됨:
|
||||
- ~~C1 (5개 섹션 미렌더링)~~: LazySection 정상 → **이슈 아님**
|
||||
- ~~C2 (매출 10배 차이)~~: NavBar=누적, 본문=당월 구분 → **이슈 아님**
|
||||
- ~~C3 (발행어음 불일치)~~: `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) → **설계 의도**
|
||||
- ~~C4 (채권추심 건수)~~: StatusBoard=레코드 7건 vs BadDebt=거래처 5곳 → **설계 의도**
|
||||
|
||||
### 🟡 Important (실제 수정 필요: 3건)
|
||||
|
||||
| # | 이슈 | 상세 | 조치 |
|
||||
|---|------|------|------|
|
||||
| I1 | **채권추심/현황판 더미 거래처명** | "(주)부산화학" 등 하드코딩 — 실제 거래처가 아님 | 백엔드 sub_label 필드 추가 → 프론트 더미값 제거 |
|
||||
| ~~I2~~ | ~~현황판 vs 채권추심 건수 불일치~~ | 현황판=`status=collecting` 레코드 7건, 채권추심=`distinct(client_id)` 거래처 5곳 | **설계 의도** (다른 관점 지표) |
|
||||
| ~~I3~~ | ~~카드사용내역 월별 합계 0원~~ | 카드 거래 20건 전부 2025-01~2026-01-28, 2/3월 거래 0건 | **버그 아님** (데이터 없음이 원인) |
|
||||
| ~~I4~~ | ~~발행어음 데이터 소스 불명확~~ | `expected_expenses`(예측)와 `bills`(실제)는 별도 테이블 | **설계 의도** (독립 데이터) |
|
||||
| I5 | **매입 "당월" 라벨 부정확** | subtitle "당월 매입 실적" + Badge "당월"이나 실제 데이터는 연간 누적(`whereYear`) | 프론트엔드 라벨 수정 |
|
||||
|
||||
### 🟢 Minor → 수정 불필요 (최종 확인)
|
||||
|
||||
| # | 이슈 | 최종 판단 |
|
||||
|---|------|----------|
|
||||
| ~~M1~~ | 미수금 산출 기준 차이 | **설계 의도** — 다른 산출 방식 |
|
||||
| ~~M2~~ | 가지급금 카드 금액 대조 불가 | **설계 의도** — 가지급금 전환 기준 vs 카드 사용 총액 |
|
||||
|
||||
### 최종 수정 필요 항목: 3건만
|
||||
|
||||
| 순위 | 이슈 | 영역 | 내용 |
|
||||
|------|------|------|------|
|
||||
| 1 | I1(B3) | 백엔드 | StatusBoardService/BadDebtService에 sub_label 필드 추가 |
|
||||
| 2 | I1(F1) | 프론트 | 더미 거래처명 상수/함수 제거 → API sub_label 사용 |
|
||||
| 3 | I5(F3) | 프론트 | 매입 섹션 "당월" → "연간"/"YTD" 라벨 수정 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 하단 섹션 추가 검증 (생산/출고/시공/근태) ✅ 완료
|
||||
|
||||
### 4-1. 생산 현황 (0공정) ✅ 정확
|
||||
|
||||
| 항목 | 대시보드 | 소스 페이지 (작업지시 관리) | 결과 |
|
||||
|------|---------|--------------------------|------|
|
||||
| 공정 수 | 0공정 | 전체 39건 (작업대기 39, 작업중 0, 완료 0) | ✅ |
|
||||
| 본문 | "오늘 등록된 작업 지시가 없습니다" | 39건 모두 2월 날짜, 상태 "미배정" | ✅ |
|
||||
|
||||
> **검증**: 대시보드 API(`dashboard/production/summary`)는 `scheduled_date = today` 기준 조회. 39건의 작업지시는 모두 2026년 2월 날짜이므로 오늘(3월 9일) 예정 작업 없음 → 0공정 정확.
|
||||
>
|
||||
> **백엔드 코드**: `DashboardCeoService.php` — `work_orders` 테이블에서 `scheduled_date = today`, `is_active = true` 조건으로 공정별 집계. 출고 데이터도 동일 API에서 `shipment` 필드로 제공.
|
||||
|
||||
### 4-2. 출고 현황 (0건/0원) ✅ 정확
|
||||
|
||||
| 항목 | 대시보드 | 소스 페이지 (출고관리) | 결과 |
|
||||
|------|---------|---------------------|------|
|
||||
| 예상 출고 (7일 이내) | 0건/0원 | 당일 출고대기 0건 | ✅ |
|
||||
| 예상 출고 (30일 이내) | 0건/0원 | 전체 8건 (모두 2025-12~2026-01) | ✅ |
|
||||
|
||||
> **검증**: 출고관리 페이지의 8건은 모두 2025년 12월~2026년 1월 날짜. 대시보드는 당월(3월) 기준 `status IN ('scheduled','ready')` 필터 → 해당 없음 → 0 정확.
|
||||
>
|
||||
> **미출고 6건**: `dashboard/unshipped/summary` API로 별도 조회. LOT번호(LOT-2024001~008), 현장명, 납기일 모두 소스 데이터와 일치. days_left가 모두 음수(D-64~D-69) → 납기 초과 상태.
|
||||
|
||||
### 4-3. 시공 현황 (0건) ✅ 비교 불가 (소스=Mock)
|
||||
|
||||
| 항목 | 대시보드 | 소스 페이지 (시공관리) | 결과 |
|
||||
|------|---------|---------------------|------|
|
||||
| 시공 진행 | 0건 | 시공진행 7건 | ⚠️ 차이 |
|
||||
| 시공 완료 | 0건 | 시공완료 4건 | ⚠️ 차이 |
|
||||
|
||||
> **원인 분석**: 시공관리 페이지(`construction/management/actions.ts`)는 **Mock 데이터 사용 중** (line 22: `// 목업 데이터`, line 21: `TODO: 실제 API 연동 시 구현`). 화면에 표시되는 "시공진행 7건"은 하드코딩된 가짜 데이터.
|
||||
>
|
||||
> 대시보드는 실제 `contracts` 테이블 조회 (`DashboardCeoService.php:555-567`) — `contract_start_date`/`contract_end_date`가 당월(3월) 범위에 해당하는 계약 없음 → 0건 정확.
|
||||
>
|
||||
> **참고**: `contracts` 테이블에서 `end_date IS NULL`인 진행 중 계약 처리 — 현재 쿼리는 `contract_end_date >= $monthEnd` 조건에서 NULL이 제외됨. 실제 계약 데이터 투입 시 이 조건의 적정성 재검토 권장 (NULL end_date = 아직 진행 중).
|
||||
|
||||
### 4-4. 근태 현황 (0명) ✅ 설계 차이
|
||||
|
||||
| 항목 | 대시보드 | 소스 페이지 (근태관리) | 결과 |
|
||||
|------|---------|---------------------|------|
|
||||
| 출근 | 0명 | 정시 출근 0명 | ✅ |
|
||||
| 지각 | 0명 | 지각 0명 | ✅ |
|
||||
| 휴가 | 0명 | 휴가 0명 | ✅ |
|
||||
| 결근 | 0명 | - | ✅ |
|
||||
| 미출근 | (미표시) | **55명** | ⚠️ 관점 차이 |
|
||||
|
||||
> **검증**: 대시보드 API(`dashboard/attendance/summary`)는 `attendances` 테이블에서 `base_date = today` 레코드만 조회 (`DashboardCeoService.php:677-694`). 오늘 출근 기록이 없으므로 모든 카운트 0, employees 배열 비어있음.
|
||||
>
|
||||
> 근태관리 페이지는 **전체 사원 명부 기반** — 등록된 55명의 사원에 대해 출근 기록 유무를 확인하고, 기록 없으면 "미출근"으로 표시.
|
||||
>
|
||||
> **설계 차이**: 대시보드="출근 기록 기반"(기록 있는 것만 카운트), 관리 페이지="사원 명부 기반"(전체 사원 대비 상태 표시). 대시보드에서 "미출근" 정보를 보여줄지는 비즈니스 결정 사항.
|
||||
>
|
||||
> **참고**: 55명 전원 "E2E_TEST_사원"(테스트 데이터), 부서/직책 모두 미지정. 실 운영 시에는 출근 기록이 생성되므로 정상 동작 예상.
|
||||
|
||||
---
|
||||
|
||||
## 검수 완료 항목
|
||||
|
||||
| 항목 | 상태 |
|
||||
|------|------|
|
||||
| Phase 1: 전체 18개 카드 수치 기록 | ✅ 완료 |
|
||||
| Phase 1: LazySection 5개 섹션 재확인 | ✅ 완료 (2차) |
|
||||
| Phase 2: 복리후생비 모달/날짜필터 | ✅ 완료 |
|
||||
| Phase 3: 소스 페이지 대조 (9개 항목) | ✅ 완료 |
|
||||
| Phase 3: 복리후생비 데이터 변경 반영 | ✅ 완료 |
|
||||
| Phase 3: 코드 분석 (transformer/fallback) | ✅ 완료 (2차) |
|
||||
| Phase 4: 하단 섹션 추가 검증 (생산/출고/시공/근태) | ✅ 완료 (3차) |
|
||||
| Phase 5: 데이터 변경 반영 테스트 | ⏸ 이슈 수정 후 |
|
||||
@@ -0,0 +1,432 @@
|
||||
# CEO 대시보드 데이터 흐름 검증 보고서
|
||||
|
||||
> **작성일**: 2026-03-06
|
||||
> **목적**: 대시보드 ↔ 개별 페이지 간 데이터 연동 완전성 검증
|
||||
> **🔴 이 문서에 정리된 데이터 레이어는 "확정된 인프라"로 고정. 디자인 변경 시 UI만 교체할 것.**
|
||||
|
||||
---
|
||||
|
||||
## 🔒 변경 금지 영역 (데이터 인프라)
|
||||
|
||||
디자인 변경 시 아래 파일들은 **절대 수정하지 않음**:
|
||||
|
||||
| 레이어 | 파일 | 역할 |
|
||||
|--------|------|------|
|
||||
| **Hooks** | `src/hooks/useCEODashboard.ts` | 23개 Hook, API 호출 |
|
||||
| **Transformers** | `src/lib/api/dashboard/transformers/*.ts` | API→Frontend 변환 |
|
||||
| **Types (API)** | `src/lib/api/dashboard/types.ts` | API 응답 타입 |
|
||||
| **Types (UI)** | `src/components/business/CEODashboard/types.ts` | UI 컴포넌트 타입 |
|
||||
| **Modal Configs** | `src/components/business/CEODashboard/modalConfigs/*.ts` | 모달 설정 |
|
||||
|
||||
디자인 변경 시 수정 가능한 파일:
|
||||
- `sections/*.tsx` (JSX/CSS만)
|
||||
- `CEODashboard.tsx` (레이아웃만)
|
||||
- `components.tsx` (공통 UI 컴포넌트)
|
||||
- `SummaryNavBar.tsx` (네비게이션)
|
||||
- `skeletons/*.ts` (로딩 UI)
|
||||
|
||||
---
|
||||
|
||||
## 📊 전체 20개 섹션 데이터 흐름 매핑
|
||||
|
||||
### 1. 상품권 → 가지급금 → 접대비 (핵심 연관관계)
|
||||
|
||||
```
|
||||
상품권 관리 (/accounting/gift-certificate)
|
||||
├─ 등록: status='holding' → cm3(상품권) 카운트 증가, 접대비 미반영
|
||||
├─ 수정: status='used' + entertainmentExpense='applicable'
|
||||
│ → Backend: syncGiftCertificateExpense() 자동 실행
|
||||
│ → expense_accounts INSERT (account_type='entertainment')
|
||||
│ → 접대비 섹션 반영됨
|
||||
├─ 조건별 접대비 분류:
|
||||
│ ├─ 일련번호 없음 → et_no_receipt (증빙미비) ✅
|
||||
│ ├─ 금액 > 50만원 → et_high_amount (고액결제) ✅
|
||||
│ └─ 주말/심야 사용 → et_weekend (주말/심야) ✅
|
||||
└─ 삭제: expense_accounts도 함께 삭제
|
||||
```
|
||||
|
||||
**검증 시나리오:**
|
||||
| # | 작업 | 기대 결과 (카드관리) | 기대 결과 (접대비) |
|
||||
|---|------|-------------------|------------------|
|
||||
| 1 | 상품권 100만원 등록 (holding) | cm3 금액 +100만원 | 미반영 |
|
||||
| 2 | status → used, 접대비=해당 | cm3 유지 | 접대비 총액 +100만원, 고액결제 +1건 |
|
||||
| 3 | 일련번호 삭제 | cm3 미증빙 +1건 | 증빙미비 +1건 |
|
||||
| 4 | status → holding 복귀 | cm3 유지 | 접대비에서 제거 |
|
||||
| 5 | 상품권 삭제 | cm3 금액 -100만원 | 접대비에서 제거 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 미수금 (ReceivableSection)
|
||||
|
||||
```
|
||||
매출관리 (/accounting/sales) → Sale 생성 → receivable_balance 증가
|
||||
미수금현황 (/accounting/receivables-status) → 입금처리/연체설정
|
||||
↓
|
||||
API: GET /api/v1/receivables/summary
|
||||
↓
|
||||
useReceivable() → transformReceivableResponse() → ReceivableSection
|
||||
```
|
||||
|
||||
**데이터 소스 → 대시보드 매핑:**
|
||||
| 소스 페이지 | 작업 | 대시보드 반영 |
|
||||
|-----------|------|------------|
|
||||
| 매출관리 | 매출 등록 | 누적미수금 증가 |
|
||||
| 미수금현황 | 입금 처리 | 누적미수금 감소 |
|
||||
| 어음관리 | 어음 발행 | 미수금 일부 이월 |
|
||||
| 미수금현황 | 연체 설정 | 체크포인트 메시지 변경 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 채권추심 (DebtCollectionSection)
|
||||
|
||||
```
|
||||
악성채권관리 (/accounting/bad-debt-collection) → BadDebt CRUD
|
||||
↓
|
||||
API: GET /api/v1/bad-debts/summary
|
||||
↓
|
||||
useDebtCollection() → transformDebtCollectionResponse() → DebtCollectionSection
|
||||
```
|
||||
|
||||
**상태 전환:**
|
||||
| 상태 | 카드 | 설명 |
|
||||
|------|------|------|
|
||||
| collecting | 추심중 | 채권 추심 진행 |
|
||||
| legalAction | 법적조치 | 법적 절차 진행 |
|
||||
| recovered | 회수완료 | 채권 회수 완료 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 매출현황 (SalesStatusSection)
|
||||
|
||||
```
|
||||
매출관리 (/accounting/sales) → Sale CRUD
|
||||
↓
|
||||
API: GET /api/v1/dashboard/sales/summary
|
||||
↓
|
||||
useSalesStatus() → transformSalesStatusResponse() → SalesStatusSection
|
||||
```
|
||||
|
||||
**대시보드 표시:** 누적매출, 달성률, 전년동기대비, 당월매출, 월별추이차트, 거래처별차트, 일별내역
|
||||
|
||||
---
|
||||
|
||||
### 5. 구매현황 (PurchaseStatusSection)
|
||||
|
||||
```
|
||||
매입관리 (/accounting/purchases) → Purchase CRUD
|
||||
↓
|
||||
API: GET /api/v1/dashboard/purchases/summary
|
||||
↓
|
||||
usePurchaseStatus() → transformPurchaseStatusResponse() → PurchaseStatusSection
|
||||
```
|
||||
|
||||
**결제 상태 매핑:**
|
||||
| DB 상태 | 표시 | 조건 |
|
||||
|--------|------|------|
|
||||
| paid | 결제완료 | withdrawal_id 있음 |
|
||||
| unpaid | 미결제 | withdrawal_id 없음 |
|
||||
| partial | 부분결제 | 일부만 결제 |
|
||||
|
||||
---
|
||||
|
||||
### 6. 카드/가지급금 (CardManagementSection)
|
||||
|
||||
```
|
||||
카드거래 + 가지급금(Loan) 데이터
|
||||
↓
|
||||
API: GET /api/proxy/card-transactions/summary + /loans/dashboard + /loans/tax-simulation
|
||||
↓
|
||||
useCardManagement() → transformCardManagementResponse() → CardManagementSection
|
||||
```
|
||||
|
||||
**5개 카드:** cm1(카드), cm2(경조사), cm3(상품권), cm4(접대비), cm_total(합계)
|
||||
|
||||
---
|
||||
|
||||
### 7. 접대비 (EntertainmentSection)
|
||||
|
||||
```
|
||||
expense_accounts 테이블 (상품권/카드 접대비 전환 시 자동 INSERT)
|
||||
↓
|
||||
API: GET /api/v1/entertainment/summary
|
||||
↓
|
||||
useEntertainment() → transformEntertainmentResponse() → EntertainmentSection
|
||||
```
|
||||
|
||||
**4개 리스크 카드:**
|
||||
| 카드 | 조건 |
|
||||
|------|------|
|
||||
| 주말/심야 | expense_date가 토/일/심야 |
|
||||
| 기피업종 | merchant_biz_type MCC 매칭 |
|
||||
| 고액결제 | amount > 500,000원 |
|
||||
| 증빙미비 | receipt_no IS NULL |
|
||||
|
||||
---
|
||||
|
||||
### 8. 복리후생비 (WelfareSection)
|
||||
|
||||
```
|
||||
지출 결재 승인 → 복리후생 관련 지출 집계
|
||||
↓
|
||||
API: GET /api/v1/welfare/summary
|
||||
↓
|
||||
useWelfare() → transformWelfareResponse() → WelfareSection
|
||||
```
|
||||
|
||||
**4개 리스크 카드:** 비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과
|
||||
|
||||
---
|
||||
|
||||
### 9. 부가세 (VatSection)
|
||||
|
||||
```
|
||||
매출/매입 거래 → 부가세 자동 계산
|
||||
↓
|
||||
API: GET /api/v1/vat/summary
|
||||
↓
|
||||
useVat() → transformVatResponse() → VatSection
|
||||
```
|
||||
|
||||
**신고 기한 색상:** D-15+(녹색), D-1~15(주황), D-0(빨강), D-(음수)(진빨강경고)
|
||||
|
||||
---
|
||||
|
||||
### 10. 당월 예상 지출 (MonthlyExpenseSection)
|
||||
|
||||
```
|
||||
구매발주 + 카드결제 + 어음 → 유형별 집계
|
||||
↓
|
||||
API: GET /api/v1/expected-expenses/summary
|
||||
↓
|
||||
useMonthlyExpense() → transformMonthlyExpenseResponse() → MonthlyExpenseSection
|
||||
```
|
||||
|
||||
**4개 카드:** 구매금액, 카드결제, 어음/외상, 전체합계
|
||||
|
||||
---
|
||||
|
||||
### 11. 일일일보 (DailyReportSection)
|
||||
|
||||
```
|
||||
배송완료(매출) + 입금기록 + 결재완료(지출) → 오늘 기준 집계
|
||||
↓
|
||||
API: GET /api/v1/daily-report/summary
|
||||
↓
|
||||
useDailyReport() → transformDailyReportResponse() → DailyReportSection
|
||||
```
|
||||
|
||||
**4개 카드:** 당일매출액, 당일입금액, 당일지출액, 당일순현금
|
||||
|
||||
---
|
||||
|
||||
### 12. 현황판 (StatusBoardSection)
|
||||
|
||||
```
|
||||
각 도메인 페이지 → 미처리 건수 집계
|
||||
↓
|
||||
API: GET /api/v1/status-board/summary
|
||||
↓
|
||||
useStatusBoard() → transformStatusBoardResponse() → StatusBoardSection
|
||||
```
|
||||
|
||||
**항목:** 수주, 채권추심, 안전재고, 세금신고, 신규업체, 연차, 차량, 장비, 결재요청
|
||||
|
||||
---
|
||||
|
||||
### 13. 오늘의 이슈 (TodayIssueSection)
|
||||
|
||||
```
|
||||
각 도메인 이벤트 발생 → TodayIssue 자동 생성
|
||||
↓
|
||||
API: GET /api/v1/today-issues/summary
|
||||
↓
|
||||
useTodayIssue() → transformTodayIssueResponse() → TodayIssueSection
|
||||
```
|
||||
|
||||
**이슈 타입:** sales_order, bad_debt, safety_stock, expected_expense, vat_report, approval_request, new_vendor, deposit, withdrawal
|
||||
|
||||
---
|
||||
|
||||
### 14. 일정/캘린더 (CalendarSection)
|
||||
|
||||
```
|
||||
일정관리 + 발주일정 + 시공일정 + 공휴일/세무일정(상수)
|
||||
↓
|
||||
API: GET /api/v1/calendar/schedules
|
||||
↓
|
||||
useCalendar() → transformCalendarResponse() → CalendarSection
|
||||
```
|
||||
|
||||
**일정 타입:** schedule(파랑), order(초록), construction(보라), holiday(빨강), tax(주황)
|
||||
|
||||
---
|
||||
|
||||
### 15. 일일생산 (DailyProductionSection)
|
||||
|
||||
```
|
||||
작업지시 상태변경 → 공정별 집계 (오늘만)
|
||||
↓
|
||||
API: GET /api/v1/dashboard/production/summary
|
||||
↓
|
||||
useDailyProduction() → transformDailyProductionResponse() → DailyProductionSection
|
||||
```
|
||||
|
||||
**공정별 탭:** 각 공정(스크린 등)의 전체/대기/진행/완료/긴급 카운트 + 작업자 진행률
|
||||
|
||||
---
|
||||
|
||||
### 16. 출하현황 (DailyProduction 내 ShipmentSection)
|
||||
|
||||
```
|
||||
shipments 테이블 → 당월 예상/실제 출고 집계
|
||||
↓
|
||||
production/summary API 내 shipment 필드
|
||||
↓
|
||||
DailyProductionSection 내 출하현황 카드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 17. 미출하 (UnshippedSection)
|
||||
|
||||
```
|
||||
출하관리 → shipments status='scheduled'|'ready'
|
||||
↓
|
||||
API: GET /api/v1/dashboard/unshipped/summary
|
||||
↓
|
||||
useUnshipped() → transformUnshippedResponse() → UnshippedSection
|
||||
```
|
||||
|
||||
**납기 색상:** ≤3일(빨강), ≤7일(주황), 이상(회색)
|
||||
|
||||
---
|
||||
|
||||
### 18. 공사현황 (ConstructionSection)
|
||||
|
||||
```
|
||||
계약관리 → contracts 당월 포함 건
|
||||
↓
|
||||
API: GET /api/v1/dashboard/construction/summary
|
||||
↓
|
||||
useConstruction() → transformConstructionResponse() → ConstructionSection
|
||||
```
|
||||
|
||||
**진행률:** (경과일/총일수) × 100, 완료=100%, 미시작=0%
|
||||
|
||||
---
|
||||
|
||||
### 19. 일일근태 (DailyAttendanceSection)
|
||||
|
||||
```
|
||||
출퇴근기록 + 휴가신청 → 오늘 기준 분류
|
||||
↓
|
||||
API: GET /api/v1/dashboard/attendance/summary
|
||||
↓
|
||||
useDailyAttendance() → transformDailyAttendanceResponse() → DailyAttendanceSection
|
||||
```
|
||||
|
||||
**상태 분류:** checkin ≤ 기준=출근, checkin > 기준=지각, leave=휴가, 없음=결근
|
||||
|
||||
---
|
||||
|
||||
### 20. Enhanced 섹션 (EnhancedSections.tsx)
|
||||
|
||||
일별 매출/매입 상세 내역 — SalesStatus/PurchaseStatus API의 daily_items 활용
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 공통 갱신 메커니즘
|
||||
|
||||
- **자동 갱신 없음**: 대시보드는 수동 refetch() 또는 페이지 새로고침 시에만 갱신
|
||||
- **sam_stat 5분 캐시**: 백엔드 통계 테이블 캐싱 (일부 섹션)
|
||||
- **대시보드 진입 시**: useCEODashboard()가 모든 섹션 병렬 로드 (Promise.all)
|
||||
|
||||
---
|
||||
|
||||
## 📋 화면 검수 시나리오 (2단계용)
|
||||
|
||||
### 시나리오 A: 상품권 → 가지급금 → 접대비
|
||||
1. 상품권 100만원 등록 (holding) → 카드관리 cm3 확인
|
||||
2. status=used, 접대비=해당으로 수정 → 접대비 고액결제 확인
|
||||
3. 일련번호 제거 → 접대비 증빙미비 확인
|
||||
4. 상태 복귀 → 접대비에서 제거 확인
|
||||
|
||||
### 시나리오 B: 매출 → 미수금
|
||||
1. 매출 등록 → 매출현황 + 미수금 증가 확인
|
||||
2. 입금 처리 → 미수금 감소 확인
|
||||
|
||||
### 시나리오 C: 작업지시 → 생산현황
|
||||
1. 작업지시 등록 (오늘) → 생산현황 대기 +1 확인
|
||||
2. 상태 → 진행중 → 진행 +1, 대기 -1 확인
|
||||
3. 상태 → 완료 → 완료 +1, 진행 -1 확인
|
||||
|
||||
### 시나리오 D: 근태
|
||||
1. 출근 기록 → 출근 인원 +1 확인
|
||||
2. 휴가 신청 승인 → 휴가 +1 확인
|
||||
|
||||
### 시나리오 E: 구매 → 지출
|
||||
1. 구매 등록 → 구매현황 + 당월예상지출 증가 확인
|
||||
2. 결제 처리 → 구매현황 미결제→결제완료 변경 확인
|
||||
|
||||
### 시나리오 F: 일일일보
|
||||
1. 배송 완료 → 당일매출액 증가 확인
|
||||
2. 입금 기록 → 당일입금액 증가 확인
|
||||
|
||||
---
|
||||
|
||||
## ✅ 화면 검수 결과 (2026-03-06 실행)
|
||||
|
||||
### 시나리오 A: 상품권 → 가지급금 → 접대비 (CRUD 전체 사이클 검증)
|
||||
|
||||
| Step | 작업 | 가지급금 상품권 | 접대비 | 결과 |
|
||||
|------|------|----------------|--------|------|
|
||||
| 1 | 100만원 등록 (holding) | 0→100만 | 미반영 | ✅ PASS |
|
||||
| 2 | status→사용, 접대비=해당 | 100만→0원 | 고액결제 +100만 1건 | ✅ PASS |
|
||||
| 3 | 일련번호 삭제 | 0원 유지 | 증빙미비 10만1건→110만2건 | ✅ PASS |
|
||||
| 4 | status→보유 복귀 | 0→100만 복귀 | 접대비에서 전부 제거 | ✅ PASS |
|
||||
| 5 | 상품권 삭제 | 100만→0원 | 변화 없음 | ✅ PASS |
|
||||
|
||||
**검증 결론**: 상품권↔가지급금↔접대비 양방향 연동 완벽 작동
|
||||
|
||||
### 전체 20개 섹션 데이터 일관성 검증 (대시보드 vs 소스 페이지)
|
||||
|
||||
| # | 섹션 | NavBar 값 | 상세 섹션 값 | API 연동 | 결과 |
|
||||
|---|------|----------|------------|---------|------|
|
||||
| 1 | 오늘의 이슈 | 2건 | 신규거래처 2건 표시 | ✅ | ✅ PASS |
|
||||
| 2 | 자금현황 | 0원 | 일일일보 0원, 미수금 9.4억, 미지급금 1.6억 | ✅ | ✅ PASS |
|
||||
| 3 | 현황판 | 7항목 | 수주0, 채권추심7, 안전재고833, 연차0 | ✅ | ✅ PASS |
|
||||
| 4 | 당월예상지출 | 1억 | 매입0, 카드0, 발행어음1억 | ✅ | ✅ PASS |
|
||||
| 5 | 가지급금 | 1,150만 | 카드1,150만, 경조사0, 상품권0, 접대비0 | ✅ | ✅ PASS |
|
||||
| 6 | 접대비 | 10만 | 주말심야0, 기피업종0, 고액결제0, 증빙미비10만1건 | ✅ | ✅ PASS |
|
||||
| 7 | 복리후생비 | 0원 | 4개 리스크 카드 모두 0원 0건 | ✅ | ✅ PASS |
|
||||
| 8 | 미수금 | 9.4억 | 누적9.4억, 당월-533만, 거래처69건, Top3 표시 | ✅ | ✅ PASS |
|
||||
| 9 | 채권추심 | 1.2억 | 추심중4,782만, 법적조치4,463만, 회수2,058만 | ✅ | ✅ PASS |
|
||||
| 10 | 부가세 | 0원 | 매출세액0, 매입세액0, 미발행0건 | ✅ | ✅ PASS |
|
||||
| 11 | 캘린더 | 26일정 | 3월 캘린더 정상, 공휴일/일정/신규업체 표시 | ✅ | ✅ PASS |
|
||||
| 12 | 매출현황 | 1억 | 누적1억343만, 당월715만, 달성률4%, 월별차트/거래처차트 | ✅ | ✅ PASS |
|
||||
| 13 | 당월매출내역 | - | 10건, 합계220만, 거래처별 필터 | ✅ | ✅ PASS |
|
||||
| 14 | 매입현황 | 165만 | 누적165만, 미결제165만, 월별차트/유형별차트 | ✅ | ✅ PASS |
|
||||
| 15 | 당월매입내역 | - | 1건, 165만, 미결제 | ✅ | ✅ PASS |
|
||||
| 16 | 생산현황 | 0공정 | "오늘 등록된 작업 지시가 없습니다" | ✅ | ✅ PASS |
|
||||
| 17 | 출고현황 | 0건 | 7일 이내 0건, 30일 이내 0건 | ✅ | ✅ PASS |
|
||||
| 18 | 미출고내역 | 6건 | 6건 목록, 포트번호/현장명/납기일/남은일 표시 | ✅ | ✅ PASS |
|
||||
| 19 | 시공현황 | 0건 | 시공진행0, 시공완료0 | ✅ | ✅ PASS |
|
||||
| 20 | 근태현황 | 0명 | 출근0, 휴가0, 지각0, 결근0 | ✅ | ✅ PASS |
|
||||
|
||||
### 매출관리 ↔ 대시보드 교차검증
|
||||
|
||||
| 소스 페이지 | 소스 값 | 대시보드 값 | 일치 |
|
||||
|-----------|---------|-----------|------|
|
||||
| 매출관리 > 당월 매출 | 7,150,000원 | 당월 매출 715만 | ✅ |
|
||||
| 매출관리 > 총 매출 | 17,050,000원 | 누적 매출 1억 343만 | ✅ (누적=해당년도) |
|
||||
| 미수금 > 자금현황 | 9억 4,145만 | 미수금 섹션 9억 4,145만 | ✅ |
|
||||
|
||||
### 최종 검수 결론
|
||||
|
||||
- **전체 20개 섹션**: API 연동 확인, 데이터 정상 표시 ✅
|
||||
- **CRUD 검증 (시나리오A)**: 등록→수정→상태변경→삭제 전 사이클 완벽 ✅
|
||||
- **교차 섹션 연동**: 상품권↔가지급금↔접대비 양방향 완벽 ✅
|
||||
- **NavBar ↔ 섹션 일관성**: 모든 NavBar 요약값과 상세 섹션값 일치 ✅
|
||||
- **소스 페이지 ↔ 대시보드 일관성**: 매출관리 등 소스 데이터와 일치 ✅
|
||||
|
||||
**🟢 CEO 대시보드 백엔드 연동 검수 완료. 데이터 인프라 확정.**
|
||||
@@ -0,0 +1,70 @@
|
||||
# 출하/배차 API 연동 — 배차 다중행 + 차량관리 + 출고관리
|
||||
|
||||
> **작업일**: 2026-03-03 ~ 03-07
|
||||
> **상태**: ✅ 완료
|
||||
> **커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
출하/배차 관련 3개 모듈의 API 연동 및 레이아웃 개선.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배차정보 다중 행 API 연동
|
||||
|
||||
기존 단일 배차 → `vehicle_dispatches` 배열 지원.
|
||||
|
||||
- [x] `ShipmentApiData`에 `vehicle_dispatches` 배열 필드 추가
|
||||
- [x] `transformApiToDetail()` — vehicle_dispatches 배열 매핑
|
||||
- [x] `transformCreateFormToApi()` — 폼 vehicleDispatches → API vehicle_dispatches 변환
|
||||
- [x] `transformEditFormToApi()` — 수정 시 동일 변환
|
||||
- [x] `transformApiToListItem()` — 첫 번째 배차의 arrival_datetime 목록에 표시
|
||||
- [x] 레거시 단일 배차 필드 하위호환 유지
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/outbound/ShipmentManagement/actions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. 배차차량관리 Mock→API 전환
|
||||
|
||||
- [x] `executePaginatedAction` + `buildApiUrl` 패턴 적용
|
||||
- [x] `transformToListItem()` — snake_case → camelCase 목록 변환
|
||||
- [x] `transformToDetail()` — snake_case → camelCase 상세 변환
|
||||
- [x] 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page`
|
||||
- [x] options/shipment 관계 데이터 중첩 API 응답에서 추출
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/outbound/VehicleDispatchManagement/actions.ts` (+207/-207)
|
||||
|
||||
---
|
||||
|
||||
## 3. 출고관리 목록 필드 매핑
|
||||
|
||||
- [x] 5개 필드 API 매핑 추가: `writer_name`, `writer_id`, `delivery_date` 등
|
||||
- [x] `OrderInfoApiData` 타입으로 주문 연결 정보 처리
|
||||
- [x] `transformApiToListItem()` 수신자/수신주소/수신처/작성자/출고일 반영
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/outbound/ShipmentManagement/actions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4. 배차 상세/수정 레이아웃
|
||||
|
||||
- [x] 기본정보 그리드: 1열 → 2×4열 레이아웃 개선
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`
|
||||
- `src/components/outbound/ShipmentManagement/ShipmentEdit.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 5. 출하관리 캘린더
|
||||
|
||||
- [x] 기본 뷰: day → week-time 변경
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/outbound/ShipmentManagement/ShipmentList.tsx`
|
||||
@@ -0,0 +1,105 @@
|
||||
# 생산지시 API 연동 + 작업자 화면 + 중간검사
|
||||
|
||||
> **작업일**: 2026-03-01 ~ 03-07
|
||||
> **상태**: ✅ 완료
|
||||
> **커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
생산지시(ProductionOrders) 목록/상세 페이지를 Mock→API 전환하고,
|
||||
작업자 화면의 중간검사 입력 모달과 자재투입 모달을 대폭 개선한 작업.
|
||||
|
||||
---
|
||||
|
||||
## 1. 생산지시 목록/상세 API 연동
|
||||
|
||||
- [x] Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴)
|
||||
- [x] 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API)
|
||||
- [x] WorkOrder 상태 배지 6단계: 미배정 → 배정 → 작업중 → 검사 → 완료 → 출하
|
||||
- [x] BOM null 상태 처리
|
||||
- [x] PO 번호 = 생산지시 번호 매핑 (별도 PO 번호 필드 불필요)
|
||||
- [x] `clientSideFiltering: false` (서버사이드 처리)
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/production/ProductionOrders/actions.ts` — 서버 액션 (getProductionOrders, getProductionOrderStats, getProductionOrderDetail)
|
||||
- `src/components/production/ProductionOrders/types.ts` — API/프론트엔드 타입 정의
|
||||
- `src/app/[locale]/(protected)/production-orders/page.tsx` — 목록 뷰
|
||||
- `src/app/[locale]/(protected)/production-orders/[id]/page.tsx` — 상세 뷰
|
||||
|
||||
---
|
||||
|
||||
## 2. 절곡 중간검사 입력 모달 (InspectionInputModal)
|
||||
|
||||
- [x] 7개 제품 항목 통합 폼
|
||||
- [x] 제품 ID 자동 매칭 (3단계): 정규화 → 키워드 → 인덱스 fallback
|
||||
- [x] cellValues 구조: `{bending_state, length, width, spacing}`
|
||||
- [x] PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유
|
||||
- [x] 데이터 로딩: bending 공정 아이템 중 inspection_data 보유 시 전체 적용
|
||||
- [x] 데이터 저장: 중간검사 완료 시 모든 workItem에 동기화
|
||||
|
||||
### 제품 ID 매칭 전략 (bending/utils.ts)
|
||||
```
|
||||
1순위: 정규화 후 정확 매치 (대소문자/공백/특수문자 제거)
|
||||
2순위: 키워드 포함 검색
|
||||
3순위: 인덱스 기반 fallback
|
||||
```
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/production/WorkerScreen/InspectionInputModal.tsx` (+396)
|
||||
- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규, +118)
|
||||
- `src/components/production/WorkOrders/documents/bending/utils.ts` (신규, +60)
|
||||
|
||||
---
|
||||
|
||||
## 3. 자재투입 모달 (MaterialInputModal)
|
||||
|
||||
- [x] 동일 자재 다중 BOM 그룹 LOT 독립 관리
|
||||
- [x] `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑
|
||||
- [x] 카테고리 정렬 순서:
|
||||
1. 가이드레일
|
||||
2. 하단마감재
|
||||
3. 셔터박스
|
||||
4. 연기차단재
|
||||
- [x] FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적
|
||||
- [x] 번호 배지 (①②③) + partType 배지
|
||||
- [x] `allGroupsFulfilled` 조건으로 입력 버튼 활성화 제어
|
||||
- [x] 그룹별 독립 전송: `bom_group_key` + `replace` 모드
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/production/WorkerScreen/MaterialInputModal.tsx` (+356)
|
||||
- `src/components/production/WorkerScreen/actions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4. 공정 단계 검사범위 설정 (InspectionScope) — 신규
|
||||
|
||||
- [x] 전수검사 / 샘플링 / 그룹 3가지 타입
|
||||
- [x] 샘플링 시 샘플 수(n) 입력 지원
|
||||
- [x] StepForm 컴포넌트에 UI 추가
|
||||
- [x] options JSON으로 API 저장
|
||||
|
||||
### 타입 정의
|
||||
```typescript
|
||||
type InspectionScopeType = 'FULL' | 'SAMPLING' | 'GROUP';
|
||||
|
||||
interface InspectionScope {
|
||||
type: InspectionScopeType;
|
||||
sampleSize?: number; // SAMPLING 타입일 때만
|
||||
}
|
||||
```
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/process-management/StepForm.tsx`
|
||||
- `src/components/process-management/actions.ts`
|
||||
- `src/types/process.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. 기타 개선
|
||||
|
||||
- [x] 작업자 화면 제품명: productCode만 표시 (간소화)
|
||||
- [x] 작업자 화면 하드코딩 도면 이미지 영역 제거
|
||||
- [x] BOM 공정 분류 접이식 카드 UI
|
||||
- [x] TemplateInspectionContent: products 배열 → cellValues 자동 매핑
|
||||
124
claudedocs/quality/[IMPL-2026-03-07] quality-api-migration.md
Normal file
124
claudedocs/quality/[IMPL-2026-03-07] quality-api-migration.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 품질관리 Mock→API 전환 및 검사 모달/문서 개선
|
||||
|
||||
> **작업일**: 2026-03-05 ~ 03-07
|
||||
> **상태**: ✅ 완료
|
||||
> **커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
품질관리(InspectionManagement) 전체 모듈을 Mock 데이터에서 실제 API로 전환하고,
|
||||
검사 모달/문서 렌더링/수주선택 기능을 대폭 개선한 작업.
|
||||
|
||||
---
|
||||
|
||||
## 1. API 전환
|
||||
|
||||
- [x] `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거
|
||||
- [x] 엔드포인트 연동
|
||||
- `GET /api/v1/quality/documents` — 검사 목록
|
||||
- `GET /api/v1/quality/documents/{id}` — 검사 상세
|
||||
- `POST /api/v1/quality/documents` — 검사 등록
|
||||
- `PUT /api/v1/quality/documents/{id}` — 검사 수정
|
||||
- `GET /api/v1/quality/performance-reports` — 실적신고 목록
|
||||
- [x] snake_case → camelCase 변환 함수 구현
|
||||
- [x] InspectionFormData 필드 추가: `clientId`, `inspectorId`, `receptionDate`
|
||||
- [x] 실적신고 API 응답 snake_case → camelCase 변환
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/quality/InspectionManagement/actions.ts`
|
||||
- `src/components/quality/PerformanceReportManagement/actions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. 검사 모달 개선 (ProductInspectionInputModal)
|
||||
|
||||
- [x] 기본값 null(미선택) 상태로 변경
|
||||
- [x] 일괄 합격/초기화 토글 버튼
|
||||
- [x] 시공 치수 필드 (너비/높이) — ConstructionInfo 인터페이스
|
||||
- [x] 변경사유 입력 필드
|
||||
- [x] 사진 첨부 (최대 2장, base64 인코딩)
|
||||
- [x] 이전/다음 개소 네비게이션 + 자동저장
|
||||
- [x] 레거시 검사 데이터 통합 (합격/불합격/진행중/미완)
|
||||
- [x] 사진 없는 항목 → "진행중" 상태 표시
|
||||
- [x] Eye 아이콘 → "보기" 텍스트 배지 변경
|
||||
- [x] 배지 사이즈 통일
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx` (+428/-210)
|
||||
|
||||
---
|
||||
|
||||
## 3. 수주선택 모달 (OrderSelectModal)
|
||||
|
||||
- [x] 발주처(clientName) 컬럼 추가
|
||||
- [x] 모델명 컬럼 추가
|
||||
- [x] 동일 발주처 + 동일 모델 필터링 제약
|
||||
- [x] 모달 너비 확장: `sm:max-w-2xl` → `sm:max-w-3xl`
|
||||
- [x] 수주 선택 시 개소 자동 펼침
|
||||
- [x] 필터 안내 텍스트 추가
|
||||
|
||||
### SearchableSelectionModal 공통 컴포넌트 확장
|
||||
- [x] `isItemDisabled` 콜백 prop 추가
|
||||
- [x] 비활성 항목 스타일링 (opacity 감소, cursor 변경)
|
||||
- [x] 전체선택 시 비활성 항목 제외
|
||||
- [x] 이미 선택된 항목은 비활성이라도 해제 가능
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/quality/InspectionManagement/OrderSelectModal.tsx`
|
||||
- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx`
|
||||
- `src/components/organisms/SearchableSelectionModal/types.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4. 제품검사 성적서 (FqcDocumentContent) — 신규
|
||||
|
||||
8컬럼 동적 렌더링 테이블 구현.
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| No | 순번 |
|
||||
| 검사항목 | 카테고리 기반 rowSpan 병합 |
|
||||
| 세부항목 | 개별 항목명 |
|
||||
| 검사기준 | 스펙/기준값 |
|
||||
| 검사방법 | method + frequency 복합 rowSpan 병합 |
|
||||
| 검사주기 | (검사방법과 함께 병합) |
|
||||
| 측정값 | measurement_type에 따라: checkbox→양호/불량, numeric→숫자입력, none→비활성 |
|
||||
| 판정 | 적합/부적합/null |
|
||||
|
||||
- [x] `buildFieldRowSpan` — 단일 필드 병합 (카테고리)
|
||||
- [x] `buildCompositeRowSpan` — 복합 필드 병합 (method+frequency)
|
||||
- [x] FQC 모드 우선 + legacy fallback 패턴
|
||||
- [x] `useImperativeHandle`로 `getInspectionData()` 외부 접근
|
||||
- [x] Lazy Snapshot 준비 (`contentWrapperRef`)
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규, +483)
|
||||
|
||||
---
|
||||
|
||||
## 5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규
|
||||
|
||||
양식 기반(template_id: 66) 동적 렌더링 구현.
|
||||
|
||||
- [x] 결재라인 섹션
|
||||
- [x] 기본정보 섹션 (7개 필드, 2컬럼 배치)
|
||||
- [x] 입력 섹션 4개: 현장, 자재유통사, 시공자, 감리
|
||||
- [x] 사전통보 테이블 (group_name 기반 3단계 헤더)
|
||||
- [x] 오픈사이즈 발주 / 시공 치수 그룹 병합
|
||||
- [x] EAV 데이터 구조: `section_id`, `column_id`, `row_index`, `field_key`, `field_value`
|
||||
- [x] EAV 문서 없을 때 legacy fallback 적용
|
||||
|
||||
### 주요 파일
|
||||
- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규, +461)
|
||||
- `src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx`
|
||||
- `src/components/quality/InspectionManagement/fqcActions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6. 수주 연결 동기화
|
||||
|
||||
- [x] `order_ids` 배열 매핑 (다중 수주 지원)
|
||||
- [x] 개소별 `inspectionData` 서버 저장
|
||||
- [x] FQC 문서에서 수주 연결 정보 동기화
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
|
||||
return (
|
||||
<BadDebtCollection
|
||||
initialData={data}
|
||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; recovered_amount: number; bad_debt_amount: number; } | null}
|
||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; collection_end_amount: number; } | null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function VendorsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'new') {
|
||||
getClients({ size: 100 })
|
||||
getClients({ size: 1000 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
|
||||
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
|
||||
// UI - 추가
|
||||
import { VisuallyHidden } from '@/components/ui/visually-hidden';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||
// Molecules - 추가
|
||||
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||
import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog';
|
||||
import { ReorderButtons } from '@/components/molecules/ReorderButtons';
|
||||
// Organisms - 추가
|
||||
import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable';
|
||||
// Lucide icons for demos
|
||||
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
|
||||
|
||||
@@ -339,6 +347,89 @@ function SearchableSelectionDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 추가 Demo Wrappers ──
|
||||
|
||||
function DateRangePickerDemo() {
|
||||
const [start, setStart] = useState<string | undefined>();
|
||||
const [end, setEnd] = useState<string | undefined>();
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
<DateRangePicker startDate={start} endDate={end} onStartDateChange={setStart} onEndDateChange={setEnd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DateTimePickerDemo() {
|
||||
const [v, setV] = useState<string | undefined>();
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
<DateTimePicker value={v} onChange={setV} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnSettingsPopoverDemo() {
|
||||
const [cols, setCols] = useState([
|
||||
{ key: 'name', label: '품목명', visible: true, locked: true },
|
||||
{ key: 'spec', label: '규격', visible: true, locked: false },
|
||||
{ key: 'qty', label: '수량', visible: true, locked: false },
|
||||
{ key: 'price', label: '단가', visible: false, locked: false },
|
||||
{ key: 'note', label: '비고', visible: false, locked: false },
|
||||
]);
|
||||
return (
|
||||
<ColumnSettingsPopover
|
||||
columns={cols}
|
||||
onToggle={(key) => setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))}
|
||||
onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))}
|
||||
hasHiddenColumns={cols.some((c) => !c.visible)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericCRUDDialogDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>CRUD 다이얼로그 열기</Button>
|
||||
<GenericCRUDDialog
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
mode="add"
|
||||
entityName="직급"
|
||||
fields={[
|
||||
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명 입력' },
|
||||
{ key: 'status', label: '상태', type: 'select', options: [{ value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' }], defaultValue: 'active' },
|
||||
]}
|
||||
onSubmit={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LineItemsTableDemo() {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' },
|
||||
{ id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' },
|
||||
]);
|
||||
return (
|
||||
<div className="max-w-3xl overflow-x-auto">
|
||||
<LineItemsTable
|
||||
items={items}
|
||||
getItemName={(i) => i.itemName}
|
||||
getQuantity={(i) => i.quantity}
|
||||
getUnitPrice={(i) => i.unitPrice}
|
||||
getSupplyAmount={(i) => i.supplyAmount}
|
||||
getVat={(i) => i.vat}
|
||||
getNote={(i) => i.note}
|
||||
onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))}
|
||||
onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])}
|
||||
onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))}
|
||||
totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview Registry ──
|
||||
|
||||
type PreviewEntry = {
|
||||
@@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
'date-range-picker.tsx': [
|
||||
{ label: 'DateRangePicker', render: () => <DateRangePickerDemo /> },
|
||||
],
|
||||
|
||||
'date-time-picker.tsx': [
|
||||
{ label: 'DateTimePicker', render: () => <DateTimePickerDemo /> },
|
||||
],
|
||||
|
||||
// ─── Atoms ───
|
||||
'BadgeSm.tsx': [
|
||||
{
|
||||
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
{ label: 'Filter', render: () => <MobileFilterDemo /> },
|
||||
],
|
||||
|
||||
'ColumnSettingsPopover.tsx': [
|
||||
{ label: 'Popover', render: () => <ColumnSettingsPopoverDemo /> },
|
||||
],
|
||||
|
||||
'GenericCRUDDialog.tsx': [
|
||||
{ label: 'CRUD Dialog', render: () => <GenericCRUDDialogDemo /> },
|
||||
],
|
||||
|
||||
'ReorderButtons.tsx': [
|
||||
{
|
||||
label: 'Sizes',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">sm:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">xs:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">disabled:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── Organisms ───
|
||||
'EmptyState.tsx': [
|
||||
{
|
||||
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
'SearchableSelectionModal.tsx': [
|
||||
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
|
||||
],
|
||||
|
||||
'LineItemsTable.tsx': [
|
||||
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
|
||||
export const MOCK_WORK_ORDER: WorkOrder = {
|
||||
id: 'wo-1',
|
||||
orderNo: 'KD-WO-240924-01',
|
||||
productCode: 'WY-SC780',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
processCode: 'screen',
|
||||
processName: 'screen',
|
||||
|
||||
@@ -229,7 +229,7 @@ export default function QualityInspectionPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
|
||||
{/* 헤더 (설정 버튼 포함) */}
|
||||
<Header
|
||||
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
||||
@@ -283,9 +283,9 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{activeDay === 1 ? (
|
||||
// ===== 기준/매뉴얼 심사 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
|
||||
{/* 좌측: 점검표 항목 */}
|
||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
||||
? 'lg:col-span-3'
|
||||
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
||||
@@ -303,7 +303,7 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{/* 중앙: 기준 문서화 */}
|
||||
{displaySettings.showDocumentSection && (
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentSection
|
||||
@@ -318,7 +318,7 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{/* 우측: 문서 뷰어 */}
|
||||
{displaySettings.showDocumentViewer && (
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentViewer document={selectedStandardDoc} />
|
||||
@@ -327,8 +327,8 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
) : (
|
||||
// ===== 로트 추적 심사 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
selectedId={selectedReport?.id || null}
|
||||
@@ -336,7 +336,7 @@ export default function QualityInspectionPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<RouteList
|
||||
routes={currentRoutes}
|
||||
selectedId={selectedRoute?.id || null}
|
||||
@@ -346,7 +346,7 @@ export default function QualityInspectionPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<DocumentList
|
||||
documents={currentDocuments}
|
||||
routeCode={selectedRoute?.code || null}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Circle,
|
||||
Activity,
|
||||
Play,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
@@ -47,143 +48,17 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 상태 타입
|
||||
type WorkOrderStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 데이터 타입
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNumber: string; // KD-WO-XXXXXX-XX
|
||||
process: string; // 공정명
|
||||
quantity: number;
|
||||
status: WorkOrderStatus;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
// 생산지시 상세 데이터 타입
|
||||
interface ProductionOrderDetail {
|
||||
id: string;
|
||||
productionOrderNumber: string;
|
||||
orderNumber: string;
|
||||
productionOrderDate: string;
|
||||
dueDate: string;
|
||||
quantity: number;
|
||||
status: ProductionOrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
productType: string;
|
||||
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
|
||||
workOrders: WorkOrder[];
|
||||
}
|
||||
|
||||
// 샘플 생산지시 상세 데이터
|
||||
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
|
||||
"PO-001": {
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-15",
|
||||
quantity: 2,
|
||||
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
|
||||
client: "호반건설(주)",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-001",
|
||||
workOrderNumber: "KD-WO-251217-07",
|
||||
process: "재단",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-002",
|
||||
workOrderNumber: "KD-WO-251217-08",
|
||||
process: "조립",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-003",
|
||||
workOrderNumber: "KD-WO-251217-09",
|
||||
process: "검수",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
"PO-002": {
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 10,
|
||||
status: "waiting",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-003": {
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 1,
|
||||
status: "waiting",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-004": {
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
productionOrderDate: "2025-12-20",
|
||||
dueDate: "2026-02-03",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
client: "현대건설(주)",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0,
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-004",
|
||||
workOrderNumber: "KD-WO-251220-01",
|
||||
process: "재단",
|
||||
quantity: 3,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-005",
|
||||
workOrderNumber: "KD-WO-251220-02",
|
||||
process: "조립",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
|
||||
import { createProductionOrder } from "@/components/orders/actions";
|
||||
import type {
|
||||
ProductionOrderDetail,
|
||||
ProductionStatus,
|
||||
ProductionWorkOrder,
|
||||
BomProcessGroup,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -202,7 +77,9 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = workOrders.filter((w) => w.status === "completed").length;
|
||||
const completedCount = workOrders.filter(
|
||||
(w) => w.status === "completed" || w.status === "shipped"
|
||||
).length;
|
||||
const totalCount = workOrders.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
@@ -237,25 +114,27 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
wo.status === "completed"
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500 text-white"
|
||||
: wo.status === "in_progress"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{wo.status === "completed" ? (
|
||||
{wo.status === "completed" || wo.status === "shipped" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{wo.process}</span>
|
||||
<span className="text-xs text-muted-foreground">{wo.processName}</span>
|
||||
</div>
|
||||
{index < workOrders.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-1 ${
|
||||
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500"
|
||||
: "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
@@ -269,13 +148,13 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<ProductionStatus, { label: string; className: string }> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
in_production: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -289,22 +168,16 @@ function getStatusBadge(status: ProductionOrderStatus) {
|
||||
}
|
||||
|
||||
// 작업지시 상태 배지 헬퍼
|
||||
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
|
||||
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
|
||||
pending: {
|
||||
label: "대기",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "작업중",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
completed: {
|
||||
label: "완료",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
function getWorkOrderStatusBadge(status: string) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
};
|
||||
const c = config[status];
|
||||
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
@@ -318,99 +191,33 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
|
||||
const SAMPLE_PROCESSES = [
|
||||
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
|
||||
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
|
||||
{ id: "P3", name: "3.1 케이스", quantity: 10 },
|
||||
{ id: "P4", name: "4. 연기단자", quantity: 10 },
|
||||
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
|
||||
];
|
||||
|
||||
// BOM 품목 타입
|
||||
interface BomItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
lotNo: string;
|
||||
requiredQty: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// BOM 공정 분류 타입
|
||||
interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
// BOM 품목별 공정 분류 목데이터
|
||||
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
|
||||
{
|
||||
processName: "1.1 백판필름",
|
||||
sizeSpec: "[20-70]",
|
||||
items: [
|
||||
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
|
||||
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
|
||||
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "2. 하안마감재",
|
||||
sizeSpec: "[60-40]",
|
||||
items: [
|
||||
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
|
||||
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "3.1 케이스",
|
||||
sizeSpec: "[500*330]",
|
||||
items: [
|
||||
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
|
||||
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
|
||||
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
|
||||
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
|
||||
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
|
||||
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "4. 연기단자",
|
||||
sizeSpec: "",
|
||||
items: [
|
||||
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
|
||||
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productionOrderId = params.id as string;
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
|
||||
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
|
||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
||||
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [bomOpen, setBomOpen] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
const result = await getProductionOrderDetail(orderId);
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setDetail(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
|
||||
setProductionOrder(found || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [productionOrderId]);
|
||||
loadDetail();
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
@@ -423,19 +230,13 @@ export default function ProductionOrderDetailPage() {
|
||||
const handleConfirmCreateWorkOrder = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
|
||||
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
|
||||
const created = Array.from({ length: workOrderCount }, (_, i) =>
|
||||
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
|
||||
);
|
||||
setCreatedWorkOrders(created);
|
||||
|
||||
// 확인 팝업 닫고 성공 팝업 열기
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
const result = await createProductionOrder(orderId);
|
||||
if (result.success) {
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} else {
|
||||
toast.error(result.error || "작업지시 생성에 실패했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -457,7 +258,7 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!productionOrder) {
|
||||
if (!detail) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="생산지시 정보를 불러올 수 없습니다"
|
||||
@@ -468,6 +269,9 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasWorkOrders = detail.workOrders.length > 0;
|
||||
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
@@ -476,9 +280,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 상세</span>
|
||||
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
{productionOrder.productionOrderNumber}
|
||||
{detail.orderNumber}
|
||||
</code>
|
||||
{getStatusBadge(productionOrder.status)}
|
||||
{getStatusBadge(detail.productionStatus)}
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
@@ -488,10 +292,7 @@ export default function ProductionOrderDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
@@ -503,7 +304,7 @@ export default function ProductionOrderDetailPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공정 진행 현황 */}
|
||||
<ProcessProgress workOrders={productionOrder.workOrders} />
|
||||
<ProcessProgress workOrders={detail.workOrders} />
|
||||
|
||||
{/* 기본 정보 & 거래처/현장 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -514,11 +315,10 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
|
||||
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
|
||||
<InfoItem label="납기일" value={productionOrder.dueDate} />
|
||||
<InfoItem label="수량" value={`${productionOrder.quantity}개`} />
|
||||
<InfoItem label="수주번호" value={detail.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
|
||||
<InfoItem label="납기일" value={detail.deliveryDate} />
|
||||
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -530,112 +330,108 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="거래처" value={productionOrder.client} />
|
||||
<InfoItem label="현장명" value={productionOrder.siteName} />
|
||||
<InfoItem label="제품유형" value={productionOrder.productType} />
|
||||
<InfoItem label="거래처" value={detail.clientName} />
|
||||
<InfoItem label="현장명" value={detail.siteName} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* BOM 품목별 공정 분류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">BOM 품목별 공정 분류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 절곡 부품 전개도 정보 헤더 */}
|
||||
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
절곡 부품 전개도 정보
|
||||
</p>
|
||||
|
||||
{/* 공정별 테이블 */}
|
||||
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
{/* 공정명 헤더 */}
|
||||
<h4 className="text-sm font-semibold">
|
||||
{group.processName}
|
||||
{group.sizeSpec && (
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
{group.sizeSpec}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{/* BOM 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">항목코드</TableHead>
|
||||
<TableHead>세부품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>LOT NO</TableHead>
|
||||
<TableHead className="text-right">필요수량</TableHead>
|
||||
<TableHead className="text-center w-[60px]">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.lotNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* BOM 품목별 공정 분류 (접이식) */}
|
||||
{detail.bomProcessGroups.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setBomOpen((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
BOM 품목별 공정 분류
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({detail.bomProcessGroups.length}개 공정)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
||||
bomOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardHeader>
|
||||
{bomOpen && (
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
{detail.bomProcessGroups.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Badge variant="outline">{group.processName}</Badge>
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
{group.items.length}건
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
{/* 합계 정보 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t text-sm">
|
||||
<span className="text-muted-foreground">총 부품 종류: 18개</span>
|
||||
<span className="text-muted-foreground">총 중량: 25.8 kg</span>
|
||||
<span className="text-muted-foreground">비고: VT칼 작업 완료 후 절곡 진행</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead>개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item, idx) => (
|
||||
<TableRow key={`${item.id}-${idx}`}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 작업지시서 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">작업지시서 목록</CardTitle>
|
||||
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{productionOrder.pendingWorkOrderCount > 1
|
||||
? "작업지시 일괄생성"
|
||||
: "작업지시 생성"}
|
||||
작업지시 생성
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productionOrder.workOrders.length === 0 ? (
|
||||
{!hasWorkOrders ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ClipboardList className="h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아직 작업지시서가 생성되지 않았습니다.
|
||||
</p>
|
||||
{productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
위 버튼을 클릭하여 BOM 기반 작업지시서를 자동 생성하세요.
|
||||
</p>
|
||||
@@ -649,23 +445,23 @@ export default function ProductionOrderDetailPage() {
|
||||
<TableRow>
|
||||
<TableHead>작업지시번호</TableHead>
|
||||
<TableHead>공정</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrder.workOrders.map((wo) => (
|
||||
{detail.workOrders.map((wo) => (
|
||||
<TableRow key={wo.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{wo.workOrderNumber}
|
||||
{wo.workOrderNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{wo.process}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개</TableCell>
|
||||
<TableCell>{wo.processName}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개소</TableCell>
|
||||
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
|
||||
<TableCell>{wo.assignee}</TableCell>
|
||||
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -676,7 +472,7 @@ export default function ProductionOrderDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
|
||||
{/* 작업지시 생성 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={isCreateWorkOrderDialogOpen}
|
||||
onOpenChange={setIsCreateWorkOrderDialogOpen}
|
||||
@@ -685,19 +481,10 @@ export default function ProductionOrderDetailPage() {
|
||||
description={
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
다음 공정에 대한 작업지시서가 생성됩니다:
|
||||
이 수주에 대한 작업지시서를 자동 생성합니다.
|
||||
</p>
|
||||
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
|
||||
<li key={process.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{process.name} ({process.quantity}개)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
BOM 기반으로 공정별 작업지시서가 생성됩니다.
|
||||
생성된 작업지시서는 생산팀에서 확인하고 작업을 진행할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -706,7 +493,7 @@ export default function ProductionOrderDetailPage() {
|
||||
loading={isCreating}
|
||||
/>
|
||||
|
||||
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
|
||||
{/* 작업지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
@@ -716,24 +503,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-foreground">
|
||||
{createdWorkOrders.length}개의 작업지시서가 공정별로 자동 생성되었습니다.
|
||||
작업지시서가 자동 생성되었습니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">생성된 작업지시서:</p>
|
||||
{createdWorkOrders.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{createdWorkOrders.map((wo, idx) => (
|
||||
<li key={wo} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{wo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
작업지시 관리 페이지로 이동합니다.
|
||||
</p>
|
||||
@@ -749,4 +521,4 @@ export default function ProductionOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,20 @@
|
||||
* 생산지시 목록 페이지
|
||||
*
|
||||
* - 수주관리 > 생산지시 보기에서 접근
|
||||
* - 진행 단계 바
|
||||
* - 진행 단계 바 (Order 상태 기반 동적)
|
||||
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
|
||||
* - IntegratedListTemplateV2 템플릿 적용
|
||||
* - 서버사이드 페이지네이션
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -29,7 +25,6 @@ import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
UniversalListPage,
|
||||
@@ -39,136 +34,63 @@ import {
|
||||
} from "@/components/templates/UniversalListPage";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus =
|
||||
| "waiting" // 생산대기
|
||||
| "in_progress" // 생산중
|
||||
| "completed"; // 생산완료
|
||||
|
||||
// 생산지시 데이터 타입
|
||||
interface ProductionOrder {
|
||||
id: string;
|
||||
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
|
||||
orderNumber: string; // KD-TS-XXXXXX-XX
|
||||
siteName: string;
|
||||
client: string;
|
||||
quantity: number;
|
||||
dueDate: string;
|
||||
productionOrderDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
workOrderCount: number;
|
||||
}
|
||||
|
||||
// 샘플 생산지시 데이터
|
||||
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
|
||||
{
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
client: "호반건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-02-15",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
client: "태영건설(주)",
|
||||
quantity: 10,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
client: "롯데건설(주)",
|
||||
quantity: 1,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
client: "현대건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2026-02-03",
|
||||
productionOrderDate: "2025-12-20",
|
||||
status: "in_progress",
|
||||
workOrderCount: 2,
|
||||
},
|
||||
{
|
||||
id: "PO-005",
|
||||
productionOrderNumber: "PO-KD-BD-251219-34",
|
||||
orderNumber: "KD-BD-251219-34",
|
||||
siteName: "[코레타스프1] 김포 6차 필라테스장",
|
||||
client: "신성플랜(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-01-15",
|
||||
productionOrderDate: "2025-12-19",
|
||||
status: "in_progress",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-006",
|
||||
productionOrderNumber: "PO-KD-TS-250401-29",
|
||||
orderNumber: "KD-TS-250401-29",
|
||||
siteName: "포레나 전주",
|
||||
client: "한화건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2025-05-16",
|
||||
productionOrderDate: "2025-04-01",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-007",
|
||||
productionOrderNumber: "PO-KD-BD-250331-28",
|
||||
orderNumber: "KD-BD-250331-28",
|
||||
siteName: "포레나 수원",
|
||||
client: "포레나건설(주)",
|
||||
quantity: 4,
|
||||
dueDate: "2025-05-15",
|
||||
productionOrderDate: "2025-03-31",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-008",
|
||||
productionOrderNumber: "PO-KD-TS-250314-23",
|
||||
orderNumber: "KD-TS-250314-23",
|
||||
siteName: "자이 흑산파크",
|
||||
client: "GS건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2025-04-28",
|
||||
productionOrderDate: "2025-03-14",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
];
|
||||
import {
|
||||
getProductionOrders,
|
||||
getProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/actions";
|
||||
import type {
|
||||
ProductionOrder,
|
||||
ProductionStatus,
|
||||
ProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
function ProgressSteps() {
|
||||
const steps = [
|
||||
{ label: "수주확정", active: true, completed: true },
|
||||
{ label: "생산지시", active: true, completed: false },
|
||||
{ label: "작업지시", active: false, completed: false },
|
||||
{ label: "생산", active: false, completed: false },
|
||||
{ label: "검사출하", active: false, completed: false },
|
||||
];
|
||||
function ProgressSteps({ statusCode }: { statusCode?: string }) {
|
||||
const getSteps = () => {
|
||||
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
|
||||
const steps = [
|
||||
{ label: "수주확정", completed: true, active: false },
|
||||
{ label: "생산지시", completed: true, active: false },
|
||||
{ label: "작업지시", completed: false, active: false },
|
||||
{ label: "생산", completed: false, active: false },
|
||||
{ label: "검사출하", completed: false, active: false },
|
||||
];
|
||||
|
||||
if (!statusCode) return steps;
|
||||
|
||||
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
|
||||
if (statusCode === "IN_PROGRESS") {
|
||||
steps[2].active = true;
|
||||
}
|
||||
// IN_PRODUCTION = 생산중
|
||||
if (statusCode === "IN_PRODUCTION") {
|
||||
steps[2].completed = true;
|
||||
steps[3].active = true;
|
||||
}
|
||||
// PRODUCED = 생산완료
|
||||
if (statusCode === "PRODUCED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPING = 출하중
|
||||
if (statusCode === "SHIPPING") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPED = 출하완료
|
||||
if (statusCode === "SHIPPED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].completed = true;
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
const steps = getSteps();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
@@ -214,16 +136,16 @@ function ProgressSteps() {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<
|
||||
ProductionOrderStatus,
|
||||
ProductionStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
in_production: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -239,13 +161,12 @@ function getStatusBadge(status: ProductionOrderStatus) {
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: "no", label: "번호", className: "w-[60px] text-center" },
|
||||
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[150px]" },
|
||||
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
|
||||
{ key: "client", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
|
||||
{ key: "dueDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "clientName", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "nodeCount", label: "개소", className: "w-[80px] text-center" },
|
||||
{ key: "deliveryDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderedAt", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "status", label: "상태", className: "w-[100px]" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
|
||||
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
|
||||
@@ -253,65 +174,21 @@ const TABLE_COLUMNS: TableColumn[] = [
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = orders.filter((item) => {
|
||||
// 탭 필터
|
||||
if (activeTab !== "all") {
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
if (item.status !== statusMap[activeTab]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
const [stats, setStats] = useState<ProductionOrderStats>({
|
||||
total: 0,
|
||||
waiting: 0,
|
||||
in_production: 0,
|
||||
completed: 0,
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 탭별 건수
|
||||
const tabCounts = {
|
||||
all: orders.length,
|
||||
waiting: orders.filter((i) => i.status === "waiting").length,
|
||||
in_progress: orders.filter((i) => i.status === "in_progress").length,
|
||||
completed: orders.filter((i) => i.status === "completed").length,
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: tabCounts.all },
|
||||
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
|
||||
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
|
||||
];
|
||||
// 통계 로드
|
||||
useEffect(() => {
|
||||
getProductionOrderStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
@@ -325,57 +202,13 @@ export default function ProductionOrdersListPage() {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
|
||||
};
|
||||
|
||||
// 개별 삭제 다이얼로그 열기
|
||||
const handleDelete = (item: ProductionOrder) => {
|
||||
setDeleteTargetId(item.id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 일괄 삭제 다이얼로그 열기
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size > 0) {
|
||||
setDeleteTargetId(null); // 일괄 삭제
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
|
||||
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
|
||||
|
||||
// 실제 삭제 실행
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetId) {
|
||||
// 개별 삭제
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetId));
|
||||
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
|
||||
} else {
|
||||
// 일괄 삭제
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
// 탭 옵션 (통계 기반 동적 카운트)
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: stats.total },
|
||||
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
|
||||
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
@@ -402,22 +235,17 @@ export default function ProductionOrdersListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{item.productionOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.orderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{item.siteName}
|
||||
</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}개</TableCell>
|
||||
<TableCell>{item.dueDate}</TableCell>
|
||||
<TableCell>{item.productionOrderDate}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell>{item.clientName}</TableCell>
|
||||
<TableCell className="text-center">{formatNumber(item.nodeCount)}개소</TableCell>
|
||||
<TableCell>{item.deliveryDate}</TableCell>
|
||||
<TableCell>{item.productionOrderedAt}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.workOrderCount > 0 ? (
|
||||
<Badge variant="outline">{item.workOrderCount}건</Badge>
|
||||
@@ -431,9 +259,6 @@ export default function ProductionOrdersListPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -463,19 +288,19 @@ export default function ProductionOrdersListPage() {
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-700 font-mono text-xs"
|
||||
>
|
||||
{item.productionOrderNumber}
|
||||
{item.orderNumber}
|
||||
</Badge>
|
||||
{getStatusBadge(item.status)}
|
||||
{getStatusBadge(item.productionStatus)}
|
||||
</>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="수주번호" value={item.orderNumber} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="거래처" value={item.client} />
|
||||
<InfoField label="수량" value={`${item.quantity}개`} />
|
||||
<InfoField label="납기" value={item.dueDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderDate} />
|
||||
<InfoField label="거래처" value={item.clientName} />
|
||||
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}개소`} />
|
||||
<InfoField label="납기" value={item.deliveryDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderedAt} />
|
||||
<InfoField
|
||||
label="작업지시"
|
||||
value={item.workOrderCount > 0 ? `${item.workOrderCount}건` : "-"}
|
||||
@@ -497,18 +322,6 @@ export default function ProductionOrdersListPage() {
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -516,6 +329,43 @@ export default function ProductionOrdersListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// getList API 호출
|
||||
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
|
||||
const productionStatus = params?.tab && params.tab !== "all"
|
||||
? (params.tab as ProductionStatus)
|
||||
: undefined;
|
||||
|
||||
const result = await getProductionOrders({
|
||||
search: params?.search,
|
||||
productionStatus,
|
||||
page: params?.page,
|
||||
perPage: params?.pageSize,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 새로고침
|
||||
getProductionOrderStats().then((statsResult) => {
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination?.total || 0,
|
||||
totalPages: result.pagination?.lastPage || 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
error: result.error,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
|
||||
title: "생산지시 목록",
|
||||
@@ -525,43 +375,19 @@ export default function ProductionOrdersListPage() {
|
||||
idField: "id",
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: orders,
|
||||
totalCount: orders.length,
|
||||
}),
|
||||
getList,
|
||||
},
|
||||
|
||||
columns: TABLE_COLUMNS,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
defaultTab: "all",
|
||||
|
||||
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
|
||||
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
|
||||
|
||||
itemsPerPage,
|
||||
itemsPerPage: 20,
|
||||
|
||||
clientSideFiltering: true,
|
||||
|
||||
searchFilter: (item, searchValue) => {
|
||||
const term = searchValue.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
},
|
||||
|
||||
tabFilter: (item, tabValue) => {
|
||||
if (tabValue === "all") return true;
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
return item.status === statusMap[tabValue];
|
||||
},
|
||||
clientSideFiltering: false,
|
||||
|
||||
headerActions: () => (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
@@ -580,50 +406,11 @@ export default function ProductionOrdersListPage() {
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
renderDialogs: () => (
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="삭제 확인"
|
||||
description={
|
||||
<>
|
||||
선택한 <strong>{deleteCount}개</strong>의 항목을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<UniversalListPage<ProductionOrder>
|
||||
config={productionOrderConfig}
|
||||
initialData={orders}
|
||||
initialTotalCount={orders.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
setSelectedItems,
|
||||
getItemId: (item: ProductionOrder) => item.id,
|
||||
}}
|
||||
onTabChange={(value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onSearchChange={setSearchTerm}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
// HTML 설정
|
||||
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const resourceType = req.resourceType();
|
||||
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
|
||||
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
|
||||
req.abort();
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
|
||||
await page.setContent(fullHtml, {
|
||||
waitUntil: 'networkidle0',
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
// 헤더 템플릿 (문서번호, 생성일)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
@@ -39,8 +40,10 @@ import type {
|
||||
} from './types';
|
||||
import {
|
||||
STATUS_SELECT_OPTIONS,
|
||||
COLLECTION_END_REASON_OPTIONS,
|
||||
VENDOR_TYPE_LABELS,
|
||||
} from './types';
|
||||
import type { CollectionEndReason } from './types';
|
||||
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
@@ -87,6 +90,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
|
||||
assignedManagerId: null,
|
||||
assignedManager: null,
|
||||
settingToggle: true,
|
||||
collectionEndReason: undefined,
|
||||
badDebtCount: 0,
|
||||
badDebts: [],
|
||||
files: [],
|
||||
@@ -134,12 +138,14 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
if (isNewMode) {
|
||||
const result = await createBadDebt(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '등록에 실패했습니다.' };
|
||||
} else {
|
||||
const result = await updateBadDebt(recordId!, formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '수정에 실패했습니다.' };
|
||||
@@ -156,6 +162,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
try {
|
||||
const result = await deleteBadDebt(String(id));
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
||||
@@ -778,22 +785,47 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(val) => handleChange('status', val as CollectionStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(val) => {
|
||||
handleChange('status', val as CollectionStatus);
|
||||
if (val !== 'collectionEnd') {
|
||||
handleChange('collectionEndReason', null);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.status === 'collectionEnd' && (
|
||||
<Select
|
||||
value={formData.collectionEndReason || ''}
|
||||
onValueChange={(val) => handleChange('collectionEndReason', val as CollectionEndReason)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="종료사유 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_END_REASON_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 연체일수 */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -72,8 +72,9 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus {
|
||||
switch (apiStatus) {
|
||||
case 'collecting': return 'collecting';
|
||||
case 'legal_action': return 'legalAction';
|
||||
case 'recovered': return 'recovered';
|
||||
case 'bad_debt': return 'badDebt';
|
||||
case 'recovered':
|
||||
case 'bad_debt':
|
||||
case 'collection_end': return 'collectionEnd';
|
||||
default: return 'collecting';
|
||||
}
|
||||
}
|
||||
@@ -82,8 +83,7 @@ function mapFrontendStatusToApi(status: CollectionStatus): string {
|
||||
switch (status) {
|
||||
case 'collecting': return 'collecting';
|
||||
case 'legalAction': return 'legal_action';
|
||||
case 'recovered': return 'recovered';
|
||||
case 'badDebt': return 'bad_debt';
|
||||
case 'collectionEnd': return 'collection_end';
|
||||
default: return 'collecting';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -56,6 +58,7 @@ const tableColumns = [
|
||||
{ key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
|
||||
// ===== Props 타입 정의 =====
|
||||
@@ -65,8 +68,7 @@ interface BadDebtCollectionProps {
|
||||
total_amount: number;
|
||||
collecting_amount: number;
|
||||
legal_action_amount: number;
|
||||
recovered_amount: number;
|
||||
bad_debt_amount: number;
|
||||
collection_end_amount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
totalAmount: initialSummary.total_amount,
|
||||
collectingAmount: initialSummary.collecting_amount,
|
||||
legalActionAmount: initialSummary.legal_action_amount,
|
||||
recoveredAmount: initialSummary.recovered_amount,
|
||||
collectionEndAmount: initialSummary.collection_end_amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,11 +146,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
const legalActionAmount = data
|
||||
.filter((d) => d.status === 'legalAction')
|
||||
.reduce((sum, d) => sum + d.debtAmount, 0);
|
||||
const recoveredAmount = data
|
||||
.filter((d) => d.status === 'recovered')
|
||||
const collectionEndAmount = data
|
||||
.filter((d) => d.status === 'collectionEnd')
|
||||
.reduce((sum, d) => sum + d.debtAmount, 0);
|
||||
|
||||
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
|
||||
return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount };
|
||||
}, [data, initialSummary]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
@@ -175,6 +177,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBadDebt(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
setData((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
@@ -335,7 +338,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
},
|
||||
{
|
||||
label: '회수완료',
|
||||
value: `${formatNumber(statsData.recoveredAmount)}원`,
|
||||
value: `${formatNumber(statsData.collectionEndAmount)}원`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
@@ -390,6 +393,27 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
disabled={isPending}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700"
|
||||
onClick={() => handlers.onDelete?.(item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// ===== 악성채권 추심관리 타입 정의 =====
|
||||
|
||||
// 추심 상태
|
||||
export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt';
|
||||
export type CollectionStatus = 'collecting' | 'legalAction' | 'collectionEnd';
|
||||
|
||||
// 추심종료 사유
|
||||
export type CollectionEndReason = 'recovered' | 'badDebt';
|
||||
|
||||
export const COLLECTION_END_REASON_OPTIONS: { value: CollectionEndReason; label: string }[] = [
|
||||
{ value: 'recovered', label: '회수완료' },
|
||||
{ value: 'badDebt', label: '대손처리' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest';
|
||||
@@ -70,6 +78,7 @@ export interface BadDebtRecord {
|
||||
debtAmount: number; // 총 미수금액
|
||||
badDebtCount: number; // 악성채권 건수
|
||||
status: CollectionStatus; // 대표 상태 (가장 최근)
|
||||
collectionEndReason?: CollectionEndReason; // 추심종료 사유 (status === 'collectionEnd'일 때)
|
||||
overdueDays: number; // 최대 연체일수
|
||||
overdueToggle: boolean;
|
||||
occurrenceDate: string;
|
||||
|
||||
@@ -51,12 +51,27 @@ import {
|
||||
getBankAccountOptions,
|
||||
getFinancialInstitutions,
|
||||
batchSaveTransactions,
|
||||
exportBankTransactionsExcel,
|
||||
type BankTransactionSummaryData,
|
||||
} from './actions';
|
||||
import { TransactionFormModal } from './TransactionFormModal';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
|
||||
// ===== 엑셀 다운로드 컬럼 =====
|
||||
const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
|
||||
{ header: '거래일시', key: 'transactionDate', width: 12 },
|
||||
{ header: '구분', key: 'type', width: 8,
|
||||
transform: (v) => v === 'deposit' ? '입금' : '출금' },
|
||||
{ header: '은행명', key: 'bankName', width: 12 },
|
||||
{ header: '계좌명', key: 'accountName', width: 15 },
|
||||
{ header: '적요/내용', key: 'note', width: 20 },
|
||||
{ header: '입금', key: 'depositAmount', width: 14 },
|
||||
{ header: '출금', key: 'withdrawalAmount', width: 14 },
|
||||
{ header: '잔액', key: 'balance', width: 14 },
|
||||
{ header: '취급점', key: 'branch', width: 12 },
|
||||
{ header: '상대계좌예금주명', key: 'depositorName', width: 18 },
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
|
||||
const tableColumns = [
|
||||
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
|
||||
}
|
||||
}, [localChanges, loadData]);
|
||||
|
||||
// 엑셀 다운로드
|
||||
// 엑셀 다운로드 (프론트 xlsx 생성)
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const result = await exportBankTransactionsExcel({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
window.open(result.data.downloadUrl, '_blank');
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: BankTransaction[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getBankTransactionList({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel({
|
||||
data: allData as (BankTransaction & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: '계좌입출금내역',
|
||||
sheetName: '입출금내역',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
||||
|
||||
|
||||
@@ -1,99 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-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 { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { billConfig } from './billConfig';
|
||||
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
|
||||
import { apiDataToFormData, transformFormDataToApi } from './types';
|
||||
import type { BillApiData } from './types';
|
||||
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
|
||||
import { useBillForm } from './hooks/useBillForm';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useBillConditions } from './hooks/useBillConditions';
|
||||
import {
|
||||
BILL_TYPE_OPTIONS,
|
||||
getBillStatusOptions,
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
BasicInfoSection,
|
||||
ElectronicBillSection,
|
||||
ExchangeBillSection,
|
||||
DiscountInfoSection,
|
||||
EndorsementSection,
|
||||
CollectionSection,
|
||||
HistorySection,
|
||||
RenewalSection,
|
||||
RecourseSection,
|
||||
BuybackSection,
|
||||
DishonoredSection,
|
||||
} from './sections';
|
||||
import { useDetailData } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
// ===== 거래처 타입 =====
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
// 거래처 목록
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
// V8 폼 훅
|
||||
const {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
} = useBillForm();
|
||||
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
// 조건부 표시 플래그
|
||||
const conditions = useBillConditions(formData);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
// 거래처 목록 로드
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
@@ -104,41 +70,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
// API 데이터 로딩 (BillApiData 그대로)
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
(id: string | number) => getBillRaw(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: billData,
|
||||
data: billApiData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
} = useDetailData<BillApiData>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
// API 데이터 → V8 폼 데이터로 변환
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
if (billApiData) {
|
||||
setFormDataFull(apiDataToFormData(billApiData));
|
||||
}
|
||||
}, [billData]);
|
||||
}, [billApiData, setFormDataFull]);
|
||||
|
||||
// ===== 로드 에러 처리 =====
|
||||
// 로드 에러
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
@@ -146,43 +101,21 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.billNumber.trim()) return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
const vendorId = conditions.isReceived ? formData.vendor : formData.payee;
|
||||
if (!vendorId) return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
if (formData.amount <= 0) return { valid: false, error: '금액을 입력해주세요.' };
|
||||
if (!formData.issueDate) return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
if (conditions.isBill && !formData.maturityDate) return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
}, [formData, conditions.isReceived, conditions.isBill]);
|
||||
|
||||
// ===== 제출 상태 =====
|
||||
// 제출
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
@@ -192,28 +125,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const billData: Partial<BillRecord> = {
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
const vendorName = clients.find(c => c.id === (conditions.isReceived ? formData.vendor : formData.payee))?.name || '';
|
||||
const apiPayload = transformFormDataToApi(formData, vendorName);
|
||||
|
||||
if (isNewMode) {
|
||||
const result = await createBill(billData);
|
||||
const result = await createBillRaw(apiPayload);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
|
||||
return { success: false, error: '' };
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return await updateBill(String(billId), billData);
|
||||
const result = await updateBillRaw(String(billId), apiPayload);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, clients, isNewMode, billId, validateForm, router]);
|
||||
}, [formData, clients, conditions.isReceived, isNewMode, billId, validateForm, router]);
|
||||
|
||||
// ===== 삭제 핸들러 (결과만 반환, 토스트/리다이렉트는 IntegratedDetailTemplate에 위임) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -223,284 +158,91 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}
|
||||
}, [billId]);
|
||||
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billNumber">
|
||||
어음번호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 1. 기본 정보 */}
|
||||
<BasicInfoSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
clients={clients}
|
||||
conditions={conditions}
|
||||
onInstrumentTypeChange={handleInstrumentTypeChange}
|
||||
onDirectionChange={handleDirectionChange}
|
||||
/>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 2. 전자어음 정보 */}
|
||||
{conditions.showElectronic && (
|
||||
<ElectronicBillSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 3. 환어음 정보 */}
|
||||
{conditions.showExchangeBill && (
|
||||
<ExchangeBillSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
showAcceptanceRefusal={conditions.showAcceptanceRefusal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 4. 할인 정보 */}
|
||||
{conditions.showDiscount && (
|
||||
<DiscountInfoSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 5. 배서양도 정보 */}
|
||||
{conditions.showEndorsement && (
|
||||
<EndorsementSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 6. 추심 정보 */}
|
||||
{conditions.showCollection && (
|
||||
<CollectionSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 7. 이력 관리 (받을어음만) */}
|
||||
{conditions.isReceived && (
|
||||
<HistorySection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
isElectronic={conditions.isElectronic}
|
||||
maxSplitCount={conditions.maxSplitCount}
|
||||
onAddInstallment={addInstallment}
|
||||
onRemoveInstallment={removeInstallment}
|
||||
onUpdateInstallment={updateInstallment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 8. 개서 정보 */}
|
||||
{conditions.showRenewal && (
|
||||
<RenewalSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 차수 관리 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-red-500">*</span> 차수 관리
|
||||
</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 9. 소구 정보 */}
|
||||
{conditions.showRecourse && (
|
||||
<RecourseSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 10. 환매 정보 */}
|
||||
{conditions.showBuyback && (
|
||||
<BuybackSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 11. 부도 정보 */}
|
||||
{conditions.showDishonored && (
|
||||
<DishonoredSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
// 템플릿 설정
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isViewMode ? '어음 상세' : '어음',
|
||||
title: isViewMode ? '어음/수표 상세' : '어음/수표',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - tableHeaderActions: 거래처, 구분, 상태 필터
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { useDateRange } from '@/hooks';
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
@@ -32,8 +31,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -91,24 +88,6 @@ export function BillManagementClient({
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
|
||||
const itemsPerPage = initialPagination.perPage;
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const deleteDialog = useDeleteDialog({
|
||||
onDelete: async (id) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
|
||||
await loadData(currentPage);
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
entityName: '어음',
|
||||
});
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
@@ -148,6 +127,16 @@ export function BillManagementClient({
|
||||
}
|
||||
}, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]);
|
||||
|
||||
// ===== 필터 변경 시 자동 재조회 =====
|
||||
const isInitialMount = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
loadData(1);
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
@@ -296,6 +285,7 @@ export function BillManagementClient({
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건이 저장되었습니다.`);
|
||||
loadData(currentPage);
|
||||
setSelectedItems(new Set());
|
||||
@@ -326,6 +316,25 @@ export function BillManagementClient({
|
||||
totalCount: pagination.total,
|
||||
};
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
await loadData(currentPage);
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '어음 삭제',
|
||||
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
@@ -348,32 +357,8 @@ export function BillManagementClient({
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendorFilter',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'billType',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendorFilter: vendorFilter,
|
||||
billType: billTypeFilter,
|
||||
status: statusFilter,
|
||||
},
|
||||
// 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠)
|
||||
filterConfig: [],
|
||||
filterTitle: '어음 필터',
|
||||
|
||||
// 날짜 선택기
|
||||
@@ -392,44 +377,12 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
|
||||
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지)
|
||||
// 데스크톱: 모두 표시
|
||||
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-3" style={{ display: 'flex' }}>
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<RadioGroup
|
||||
value={billTypeFilter}
|
||||
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="received" id="received" />
|
||||
<Label htmlFor="received" className="cursor-pointer text-sm whitespace-nowrap">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer text-sm whitespace-nowrap">발행</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
@@ -448,7 +401,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={billTypeFilter} onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}>
|
||||
<Select value={billTypeFilter} onValueChange={setBillTypeFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
@@ -461,7 +414,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); loadData(1); }}>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="min-w-[110px] w-auto">
|
||||
<SelectValue placeholder="보관중" />
|
||||
</SelectTrigger>
|
||||
@@ -493,6 +446,7 @@ export function BillManagementClient({
|
||||
isLoading,
|
||||
router,
|
||||
loadData,
|
||||
currentPage,
|
||||
handleSave,
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
@@ -519,14 +473,6 @@ export function BillManagementClient({
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialog.single.isOpen}
|
||||
onOpenChange={deleteDialog.single.onOpenChange}
|
||||
onConfirm={deleteDialog.single.confirm}
|
||||
title="어음 삭제"
|
||||
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
||||
loading={deleteDialog.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ interface BillSummaryApiData {
|
||||
// ===== 어음 목록 조회 =====
|
||||
export async function getBills(params: {
|
||||
search?: string; billType?: string; status?: string; clientId?: string;
|
||||
isElectronic?: boolean; issueStartDate?: string; issueEndDate?: string;
|
||||
isElectronic?: boolean; instrumentType?: string; medium?: string;
|
||||
issueStartDate?: string; issueEndDate?: string;
|
||||
maturityStartDate?: string; maturityEndDate?: string;
|
||||
sortBy?: string; sortDir?: string; perPage?: number; page?: number;
|
||||
}) {
|
||||
@@ -30,6 +31,8 @@ export async function getBills(params: {
|
||||
status: params.status && params.status !== 'all' ? params.status : undefined,
|
||||
client_id: params.clientId,
|
||||
is_electronic: params.isElectronic,
|
||||
instrument_type: params.instrumentType && params.instrumentType !== 'all' ? params.instrumentType : undefined,
|
||||
medium: params.medium && params.medium !== 'all' ? params.medium : undefined,
|
||||
issue_start_date: params.issueStartDate,
|
||||
issue_end_date: params.issueEndDate,
|
||||
maturity_start_date: params.maturityStartDate,
|
||||
@@ -124,10 +127,38 @@ export async function getBillSummary(params: {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 상세 조회 (BillApiData 그대로 반환) =====
|
||||
export async function getBillRaw(id: string): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bills/${id}`),
|
||||
errorMessage: '어음 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 등록 (raw payload) =====
|
||||
export async function createBillRaw(data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bills'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '어음 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 수정 (raw payload) =====
|
||||
export async function updateBillRaw(id: string, data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bills/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
errorMessage: '어음 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 =====
|
||||
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
|
||||
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
|
||||
* (차수 관리 테이블 등 특수 기능 유지)
|
||||
*/
|
||||
export const billConfig: DetailConfig = {
|
||||
title: '어음 상세',
|
||||
description: '어음 및 수취어음 상세 현황을 관리합니다',
|
||||
title: '어음/수표 상세',
|
||||
description: '어음/수표 상세 현황을 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/accounting/bills',
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
@@ -25,8 +25,8 @@ export const billConfig: DetailConfig = {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
deleteConfirmMessage: {
|
||||
title: '어음 삭제',
|
||||
description: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
title: '어음/수표 삭제',
|
||||
description: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
178
src/components/accounting/BillManagement/constants.ts
Normal file
178
src/components/accounting/BillManagement/constants.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// ===== 증권종류 =====
|
||||
export const INSTRUMENT_TYPE_OPTIONS = [
|
||||
{ value: 'promissory', label: '약속어음' },
|
||||
{ value: 'exchange', label: '환어음' },
|
||||
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
|
||||
{ value: 'currentCheck', label: '당좌수표' },
|
||||
] as const;
|
||||
|
||||
// ===== 거래방향 =====
|
||||
export const DIRECTION_OPTIONS = [
|
||||
{ value: 'received', label: '수취 (받을어음)' },
|
||||
{ value: 'issued', label: '발행 (지급어음)' },
|
||||
] as const;
|
||||
|
||||
// ===== 전자/지류 =====
|
||||
export const MEDIUM_OPTIONS = [
|
||||
{ value: 'electronic', label: '전자' },
|
||||
{ value: 'paper', label: '지류 (종이)' },
|
||||
] as const;
|
||||
|
||||
// ===== 배서 여부 =====
|
||||
export const ENDORSEMENT_OPTIONS = [
|
||||
{ value: 'endorsable', label: '배서 가능' },
|
||||
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
|
||||
] as const;
|
||||
|
||||
// ===== 어음구분 =====
|
||||
export const BILL_CATEGORY_OPTIONS = [
|
||||
{ value: 'commercial', label: '상업어음 (매출채권)' },
|
||||
{ value: 'other', label: '기타어음 (대여금/미수금)' },
|
||||
] as const;
|
||||
|
||||
// ===== 받을어음 - 결제상태 (어음용) =====
|
||||
export const RECEIVED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'discounted', label: '할인' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료' },
|
||||
{ value: 'renewed', label: '개서 (만기연장)' },
|
||||
{ value: 'recourse', label: '소구 (배서어음 상환)' },
|
||||
{ value: 'buyback', label: '환매 (할인어음 부도)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 받을수표 - 결제상태 (수표용) =====
|
||||
export const RECEIVED_CHECK_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'deposited', label: '추심입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료 (제시입금)' },
|
||||
{ value: 'recourse', label: '소구 (수표법 제39조)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급어음 - 지급상태 =====
|
||||
export const ISSUED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityPayment', label: '만기결제' },
|
||||
{ value: 'paid', label: '결제완료' },
|
||||
{ value: 'renewed', label: '개서 (만기연장)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급수표 - 지급상태 =====
|
||||
export const ISSUED_CHECK_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '미결제' },
|
||||
{ value: 'paid', label: '결제완료 (제시출금)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 결제방법 =====
|
||||
export const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'autoTransfer', label: '만기자동이체' },
|
||||
{ value: 'currentAccount', label: '당좌결제' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 부도사유 =====
|
||||
export const DISHONOR_REASON_OPTIONS = [
|
||||
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
|
||||
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
|
||||
{ value: 'formal_defect', label: '형식불비' },
|
||||
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
|
||||
{ value: 'expired', label: '제시기간 경과' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 이력 처리구분 =====
|
||||
export const HISTORY_TYPE_OPTIONS = [
|
||||
{ value: 'received', label: '수취' },
|
||||
{ value: 'endorsement', label: '배서양도' },
|
||||
{ value: 'splitEndorsement', label: '분할배서' },
|
||||
{ value: 'collection', label: '추심의뢰' },
|
||||
{ value: 'collectionDeposit', label: '추심입금' },
|
||||
{ value: 'discount', label: '할인' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
{ value: 'recourse', label: '소구' },
|
||||
{ value: 'buyback', label: '환매' },
|
||||
{ value: 'renewal', label: '개서' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 배서차수 (지류: 4차, 전자: 20차) =====
|
||||
export const ENDORSEMENT_ORDER_PAPER = [
|
||||
{ value: '1', label: '1차 (발행인 직접수취)' },
|
||||
{ value: '2', label: '2차 (1개 업체 경유)' },
|
||||
{ value: '3', label: '3차 (2개 업체 경유)' },
|
||||
{ value: '4', label: '4차 (3개 업체 경유)' },
|
||||
] as const;
|
||||
|
||||
export const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`,
|
||||
}));
|
||||
|
||||
// ===== 보관장소 =====
|
||||
export const STORAGE_OPTIONS = [
|
||||
{ value: 'safe', label: '금고' },
|
||||
{ value: 'bank', label: '은행 보관' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급장소 (어음법 제75조) =====
|
||||
export const PAYMENT_PLACE_OPTIONS = [
|
||||
{ value: 'issuerBank', label: '발행은행 본점' },
|
||||
{ value: 'issuerBankBranch', label: '발행은행 지점' },
|
||||
{ value: 'payerAddress', label: '지급인 주소지' },
|
||||
{ value: 'designatedBank', label: '지정 은행' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 수표 지급장소 (수표법 제3조: 은행만) =====
|
||||
export const PAYMENT_PLACE_CHECK_OPTIONS = [
|
||||
{ value: 'issuerBank', label: '발행은행 본점' },
|
||||
{ value: 'issuerBankBranch', label: '발행은행 지점' },
|
||||
{ value: 'designatedBank', label: '지정 은행' },
|
||||
] as const;
|
||||
|
||||
// ===== 추심결과 =====
|
||||
export const COLLECTION_RESULT_OPTIONS = [
|
||||
{ value: 'success', label: '추심 성공 (입금완료)' },
|
||||
{ value: 'partial', label: '일부 입금' },
|
||||
{ value: 'failed', label: '추심 실패 (부도)' },
|
||||
{ value: 'pending', label: '추심 진행중' },
|
||||
] as const;
|
||||
|
||||
// ===== 소구사유 =====
|
||||
export const RECOURSE_REASON_OPTIONS = [
|
||||
{ value: 'endorsedDishonor', label: '배서양도 어음 부도' },
|
||||
{ value: 'discountDishonor', label: '할인 어음 부도 (환매)' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 인수거절 사유 =====
|
||||
export const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [
|
||||
{ value: 'financialDifficulty', label: '자금 사정 곤란' },
|
||||
{ value: 'disputeOfClaim', label: '채권 분쟁' },
|
||||
{ value: 'amountDispute', label: '금액 이의' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 개서 사유 =====
|
||||
export const RENEWAL_REASON_OPTIONS = [
|
||||
{ value: 'maturityExtension', label: '만기일 연장' },
|
||||
{ value: 'amountChange', label: '금액 변경' },
|
||||
{ value: 'conditionChange', label: '조건 변경' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 수표 관련 유효 상태 목록 (증권종류 전환 시 검증용) =====
|
||||
export const VALID_CHECK_RECEIVED_STATUSES = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored'];
|
||||
export const VALID_CHECK_ISSUED_STATUSES = ['stored', 'paid', 'dishonored'];
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BillFormData } from '../types';
|
||||
import {
|
||||
RECEIVED_STATUS_OPTIONS,
|
||||
RECEIVED_CHECK_STATUS_OPTIONS,
|
||||
ISSUED_STATUS_OPTIONS,
|
||||
ISSUED_CHECK_STATUS_OPTIONS,
|
||||
PAYMENT_PLACE_OPTIONS,
|
||||
PAYMENT_PLACE_CHECK_OPTIONS,
|
||||
} from '../constants';
|
||||
|
||||
export function useBillConditions(formData: BillFormData) {
|
||||
return useMemo(() => {
|
||||
const isReceived = formData.direction === 'received';
|
||||
const isIssued = formData.direction === 'issued';
|
||||
const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck';
|
||||
const isBill = !isCheck;
|
||||
const canBeElectronic = formData.instrumentType === 'promissory';
|
||||
const isElectronic = formData.medium === 'electronic';
|
||||
|
||||
const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus;
|
||||
|
||||
// 조건부 섹션 표시 플래그
|
||||
const showElectronic = isElectronic;
|
||||
const showExchangeBill = formData.instrumentType === 'exchange';
|
||||
const showDiscount = isReceived && formData.isDiscounted && isBill;
|
||||
const showEndorsement = isReceived && formData.receivedStatus === 'endorsed';
|
||||
const showCollection = isReceived && formData.receivedStatus === 'collected';
|
||||
const showDishonored = currentStatus === 'dishonored';
|
||||
const showRenewal = currentStatus === 'renewed' && isBill;
|
||||
const showRecourse = isReceived && formData.receivedStatus === 'recourse';
|
||||
const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill;
|
||||
const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused';
|
||||
|
||||
// 현재 증권종류에 맞는 옵션 목록
|
||||
const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS;
|
||||
const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS;
|
||||
const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS;
|
||||
|
||||
// 분할배서 최대 횟수
|
||||
const maxSplitCount = isElectronic ? 4 : 10;
|
||||
|
||||
return {
|
||||
isReceived,
|
||||
isIssued,
|
||||
isCheck,
|
||||
isBill,
|
||||
canBeElectronic,
|
||||
isElectronic,
|
||||
currentStatus,
|
||||
showElectronic,
|
||||
showExchangeBill,
|
||||
showDiscount,
|
||||
showEndorsement,
|
||||
showCollection,
|
||||
showDishonored,
|
||||
showRenewal,
|
||||
showRecourse,
|
||||
showBuyback,
|
||||
showAcceptanceRefusal,
|
||||
receivedStatusOptions,
|
||||
issuedStatusOptions,
|
||||
paymentPlaceOptions,
|
||||
maxSplitCount,
|
||||
};
|
||||
}, [formData.direction, formData.instrumentType, formData.medium, formData.isDiscounted, formData.receivedStatus, formData.issuedStatus, formData.acceptanceStatus]);
|
||||
}
|
||||
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { BillFormData } from '../types';
|
||||
import { INITIAL_BILL_FORM_DATA } from '../types';
|
||||
import {
|
||||
VALID_CHECK_RECEIVED_STATUSES,
|
||||
VALID_CHECK_ISSUED_STATUSES,
|
||||
} from '../constants';
|
||||
|
||||
export function useBillForm(initialData?: Partial<BillFormData>) {
|
||||
const [formData, setFormData] = useState<BillFormData>({
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
...initialData,
|
||||
});
|
||||
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(field: K, value: BillFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 증권종류 변경 시 연관 필드 초기화
|
||||
const handleInstrumentTypeChange = useCallback((newType: string) => {
|
||||
setFormData(prev => {
|
||||
const next = { ...prev, instrumentType: newType as BillFormData['instrumentType'] };
|
||||
const isCheckType = newType === 'cashierCheck' || newType === 'currentCheck';
|
||||
|
||||
// 약속어음 외에는 전자 불가 → 지류로 리셋
|
||||
if (newType !== 'promissory' && prev.medium === 'electronic') {
|
||||
next.medium = 'paper';
|
||||
}
|
||||
|
||||
// 수표 전환 시: 만기일, 할인, 관련 필드 리셋
|
||||
if (isCheckType) {
|
||||
next.maturityDate = '';
|
||||
next.isDiscounted = false;
|
||||
if (!VALID_CHECK_RECEIVED_STATUSES.includes(prev.receivedStatus)) {
|
||||
next.receivedStatus = 'stored';
|
||||
}
|
||||
if (!VALID_CHECK_ISSUED_STATUSES.includes(prev.issuedStatus)) {
|
||||
next.issuedStatus = 'stored';
|
||||
}
|
||||
if (prev.paymentPlace === 'payerAddress' || prev.paymentPlace === 'other') {
|
||||
next.paymentPlace = '';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 거래방향 변경 시 상태 초기화
|
||||
const handleDirectionChange = useCallback((newDirection: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
direction: newDirection as BillFormData['direction'],
|
||||
receivedStatus: 'stored',
|
||||
issuedStatus: 'stored',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 이력 관리
|
||||
const addInstallment = useCallback(() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [
|
||||
...prev.installments,
|
||||
{ id: `inst-${Date.now()}`, date: '', type: 'other', amount: 0, counterparty: '', note: '' },
|
||||
],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateInstallment = useCallback((id: string, field: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 폼 전체 덮어쓰기 (API 데이터 로드 시)
|
||||
const setFormDataFull = useCallback((data: BillFormData) => {
|
||||
setFormData(data);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getBills, deleteBill, updateBillStatus } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import { extractUniqueOptions } from '../shared';
|
||||
import {
|
||||
@@ -209,6 +210,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
|
||||
await loadBills();
|
||||
}
|
||||
@@ -247,6 +249,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
// 서버에서 재조회 (pagination 메타데이터 포함)
|
||||
await loadBills();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import {
|
||||
INSTRUMENT_TYPE_OPTIONS,
|
||||
DIRECTION_OPTIONS,
|
||||
MEDIUM_OPTIONS,
|
||||
ENDORSEMENT_OPTIONS,
|
||||
BILL_CATEGORY_OPTIONS,
|
||||
STORAGE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
ENDORSEMENT_ORDER_PAPER,
|
||||
ENDORSEMENT_ORDER_ELECTRONIC,
|
||||
} from '../constants';
|
||||
|
||||
interface BasicInfoSectionProps extends SectionProps {
|
||||
clients: { id: string; name: string }[];
|
||||
conditions: {
|
||||
isReceived: boolean;
|
||||
isIssued: boolean;
|
||||
isCheck: boolean;
|
||||
isBill: boolean;
|
||||
canBeElectronic: boolean;
|
||||
isElectronic: boolean;
|
||||
receivedStatusOptions: readonly { value: string; label: string }[];
|
||||
issuedStatusOptions: readonly { value: string; label: string }[];
|
||||
paymentPlaceOptions: readonly { value: string; label: string }[];
|
||||
};
|
||||
onInstrumentTypeChange: (v: string) => void;
|
||||
onDirectionChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export function BasicInfoSection({
|
||||
formData, updateField, isViewMode, clients, conditions, onInstrumentTypeChange, onDirectionChange,
|
||||
}: BasicInfoSectionProps) {
|
||||
const {
|
||||
isReceived, isIssued, isCheck, isBill, canBeElectronic, isElectronic,
|
||||
receivedStatusOptions, issuedStatusOptions, paymentPlaceOptions,
|
||||
} = conditions;
|
||||
|
||||
const endorsementOrderOptions = useMemo(
|
||||
() => isElectronic ? ENDORSEMENT_ORDER_ELECTRONIC : [...ENDORSEMENT_ORDER_PAPER],
|
||||
[isElectronic]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>어음번호 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.billNumber} onChange={(e) => updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 증권종류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>증권종류 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.instrumentType} onValueChange={onInstrumentTypeChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTRUMENT_TYPE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래방향 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.direction} onValueChange={onDirectionChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전자/지류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>전자/지류 <span className="text-red-500">*</span>
|
||||
{!canBeElectronic && <span className="text-xs text-muted-foreground ml-1">(전자어음법: 약속어음만 전자 가능)</span>}
|
||||
</Label>
|
||||
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v as 'electronic' | 'paper')} disabled={isViewMode || !canBeElectronic}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEDIUM_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '거래처 (발행인)' : '수취인 (거래처)'} <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={isReceived ? formData.vendor : formData.payee}
|
||||
onValueChange={(v) => updateField(isReceived ? 'vendor' : 'payee', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.amount} onChange={(v) => updateField('amount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.issueDate} onChange={(d) => updateField('issueDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 만기일 (수표는 일람출급이므로 없음) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>만기일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.maturityDate} onChange={(d) => updateField('maturityDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 은행 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '발행은행' : '결제은행'}</Label>
|
||||
<Input
|
||||
value={isReceived ? formData.issuerBank : formData.settlementBank}
|
||||
onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)}
|
||||
placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 <span className="text-red-500">*</span>
|
||||
{isCheck && <span className="text-xs text-muted-foreground ml-1">(수표: 은행만)</span>}
|
||||
</Label>
|
||||
<Select value={formData.paymentPlace} onValueChange={(v) => updateField('paymentPlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentPlaceOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 상세 */}
|
||||
{formData.paymentPlace === 'other' && (
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 상세</Label>
|
||||
<Input value={formData.paymentPlaceDetail} onChange={(e) => updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 어음구분 (어음만) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>어음구분</Label>
|
||||
<Select value={formData.billCategory} onValueChange={(v) => updateField('billCategory', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_CATEGORY_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 받을어음 전용 필드 ===== */}
|
||||
{isReceived && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 여부</Label>
|
||||
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDORSEMENT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>배서차수</Label>
|
||||
<Select value={formData.endorsementOrder} onValueChange={(v) => updateField('endorsementOrder', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{endorsementOrderOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>보관장소</Label>
|
||||
<Select value={formData.storagePlace} onValueChange={(v) => updateField('storagePlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORAGE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>결제상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.receivedStatus} onValueChange={(v) => updateField('receivedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{receivedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 할인여부 (수표 제외) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>할인여부</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.isDiscounted} onCheckedChange={(c) => {
|
||||
updateField('isDiscounted', c);
|
||||
if (c) updateField('receivedStatus', 'discounted');
|
||||
}} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.isDiscounted ? '할인 적용' : '미적용'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 지급어음 전용 필드 ===== */}
|
||||
{isIssued && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>결제방법</Label>
|
||||
<Select value={formData.paymentMethod} onValueChange={(v) => updateField('paymentMethod', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>지급상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.issuedStatus} onValueChange={(v) => updateField('issuedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{issuedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>실제결제일</Label>
|
||||
<DatePicker value={formData.actualPaymentDate} onChange={(d) => updateField('actualPaymentDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 입출금 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입금/출금 계좌</Label>
|
||||
<Input value={formData.bankAccountInfo} onChange={(e) => updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input value={formData.note} onChange={(e) => updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function BuybackSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">환매 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">할인한 어음이 부도나 금융기관이 할인 의뢰인에게 어음금액을 청구(환매)한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>환매일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.buybackDate} onChange={(d) => updateField('buybackDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.buybackAmount} onChange={(v) => updateField('buybackAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매요청 은행</Label>
|
||||
<Input value={formData.buybackBank} onChange={(e) => updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { COLLECTION_RESULT_OPTIONS } from '../constants';
|
||||
|
||||
export function CollectionSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">추심 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 추심 의뢰 */}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">추심 의뢰</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심은행 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.collectionBank} onChange={(e) => updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심의뢰일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.collectionRequestDate} onChange={(d) => updateField('collectionRequestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심수수료</Label>
|
||||
<CurrencyInput value={formData.collectionFee} onChange={(v) => updateField('collectionFee', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 추심 결과 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">추심 결과</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심결과</Label>
|
||||
<Select value={formData.collectionResult} onValueChange={(v) => updateField('collectionResult', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_RESULT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심완료일</Label>
|
||||
<DatePicker value={formData.collectionCompleteDate} onChange={(d) => updateField('collectionCompleteDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금일</Label>
|
||||
<DatePicker value={formData.collectionDepositDate} onChange={(d) => updateField('collectionDepositDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금액 (수수료 차감후)</Label>
|
||||
<CurrencyInput value={formData.collectionDepositAmount} onChange={(v) => updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function DiscountInfoSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
const calcNetReceived = useMemo(() => {
|
||||
if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount;
|
||||
return 0;
|
||||
}, [formData.amount, formData.discountAmount]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">할인 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>할인일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.discountDate} onChange={(d) => updateField('discountDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인처 (은행) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.discountBank} onChange={(e) => updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인율 (%)</Label>
|
||||
<Input type="number" step="0.01" min={0} max={100} value={formData.discountRate || ''} onChange={(e) => {
|
||||
const rate = parseFloat(e.target.value) || 0;
|
||||
updateField('discountRate', rate);
|
||||
if (formData.amount > 0 && rate > 0) updateField('discountAmount', Math.round(formData.amount * rate / 100));
|
||||
}} placeholder="예: 3.5" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인금액</Label>
|
||||
<CurrencyInput value={formData.discountAmount} onChange={(v) => updateField('discountAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>실수령액 (자동계산)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
|
||||
{calcNetReceived > 0
|
||||
? <span className="text-green-700">₩ {calcNetReceived.toLocaleString()}</span>
|
||||
: <span className="text-gray-400">어음금액 - 할인금액</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { DISHONOR_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function DishonoredSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
|
||||
부도 정보
|
||||
<Badge variant="destructive" className="text-xs">부도</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부도일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.dishonoredDate} onChange={(d) => {
|
||||
updateField('dishonoredDate', d);
|
||||
if (d) {
|
||||
const dt = new Date(d);
|
||||
dt.setDate(dt.getDate() + 6);
|
||||
updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]);
|
||||
}
|
||||
}} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부도사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISHONOR_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 법적 프로세스 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">법적 프로세스 (어음법 제44조·제45조)</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.hasProtest} onCheckedChange={(c) => updateField('hasProtest', c)} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.hasProtest ? '작성 완료' : '미작성'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{formData.hasProtest && (
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성일</Label>
|
||||
<DatePicker value={formData.protestDate} onChange={(d) => updateField('protestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>소구 통지일</Label>
|
||||
<DatePicker value={formData.recourseNoticeDate} onChange={(d) => updateField('recourseNoticeDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>통지 기한 (자동: 부도일+4영업일)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm">
|
||||
{formData.recourseNoticeDeadline ? (
|
||||
<span className={
|
||||
formData.recourseNoticeDate && formData.recourseNoticeDate <= formData.recourseNoticeDeadline
|
||||
? 'text-green-700' : 'text-red-600 font-medium'
|
||||
}>
|
||||
{formData.recourseNoticeDeadline}
|
||||
{formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'}
|
||||
</span>
|
||||
) : <span className="text-gray-400">부도일자 입력 시 자동계산</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'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 { SectionProps } from './types';
|
||||
|
||||
export function ElectronicBillSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">전자어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>전자어음 관리번호</Label>
|
||||
<Input value={formData.electronicBillNo} onChange={(e) => updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>등록기관</Label>
|
||||
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kftc">금융결제원</SelectItem>
|
||||
<SelectItem value="bank">거래은행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
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 { SectionProps } from './types';
|
||||
|
||||
export function EndorsementSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">배서양도 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>배서일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.endorsementDate} onChange={(d) => updateField('endorsementDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>피배서인 (양수인) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.endorsee} onChange={(e) => updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 사유</Label>
|
||||
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="payment">대금결제</SelectItem>
|
||||
<SelectItem value="guarantee">담보제공</SelectItem>
|
||||
<SelectItem value="collection">추심위임</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
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 { SectionProps } from './types';
|
||||
import { ACCEPTANCE_REFUSAL_REASON_OPTIONS } from '../constants';
|
||||
|
||||
interface ExchangeBillSectionProps extends SectionProps {
|
||||
showAcceptanceRefusal: boolean;
|
||||
}
|
||||
|
||||
export function ExchangeBillSection({ formData, updateField, isViewMode, showAcceptanceRefusal }: ExchangeBillSectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">환어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>지급인 (Drawee) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.drawee} onChange={(e) => updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수 여부</Label>
|
||||
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accepted">인수 완료</SelectItem>
|
||||
<SelectItem value="pending">인수 대기</SelectItem>
|
||||
<SelectItem value="refused">인수 거절</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{formData.acceptanceStatus === 'refused' ? '인수거절일' : '인수일자'}</Label>
|
||||
<DatePicker
|
||||
value={formData.acceptanceStatus === 'refused' ? formData.acceptanceRefusalDate : formData.acceptanceDate}
|
||||
onChange={(d) => updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showAcceptanceRefusal && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다.</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>인수거절 사유</Label>
|
||||
<Select value={formData.acceptanceRefusalReason} onValueChange={(v) => updateField('acceptanceRefusalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCEPTANCE_REFUSAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Plus, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { BillFormData } from '../types';
|
||||
import { HISTORY_TYPE_OPTIONS } from '../constants';
|
||||
|
||||
interface HistorySectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
isElectronic: boolean;
|
||||
maxSplitCount: number;
|
||||
onAddInstallment: () => void;
|
||||
onRemoveInstallment: (id: string) => void;
|
||||
onUpdateInstallment: (id: string, field: string, value: string | number) => void;
|
||||
}
|
||||
|
||||
export function HistorySection({
|
||||
formData, updateField, isViewMode, isElectronic, maxSplitCount,
|
||||
onAddInstallment, onRemoveInstallment, onUpdateInstallment,
|
||||
}: HistorySectionProps) {
|
||||
const splitEndorsementStats = useMemo(() => {
|
||||
const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement');
|
||||
const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0);
|
||||
return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount };
|
||||
}, [formData.installments, formData.amount]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">이력 관리</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" size="sm" onClick={onAddInstallment} className="text-orange-500 border-orange-300 hover:bg-orange-50">
|
||||
<Plus className="h-4 w-4 mr-1" />추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 분할배서 토글 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formData.isSplit} onCheckedChange={(c) => updateField('isSplit', c)} disabled={isViewMode} />
|
||||
<Label>분할배서 허용</Label>
|
||||
{formData.isSplit && (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
최대 {maxSplitCount}회
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{formData.isSplit && isElectronic && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조)</span>
|
||||
</div>
|
||||
)}
|
||||
{formData.isSplit && splitEndorsementStats.count > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm bg-gray-50 rounded-md px-3 py-2">
|
||||
<span className="text-muted-foreground">원금액:</span>
|
||||
<span className="font-semibold">₩ {formData.amount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 분할배서 합계:</span>
|
||||
<span className="font-semibold text-blue-600">₩ {splitEndorsementStats.totalAmount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 잔액:</span>
|
||||
<span className={`font-semibold ${splitEndorsementStats.remaining < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
₩ {splitEndorsementStats.remaining.toLocaleString()}
|
||||
</span>
|
||||
{splitEndorsementStats.remaining < 0 && (
|
||||
<span className="text-red-500 text-xs flex items-center gap-1"><AlertTriangle className="h-3 w-3" />금액 초과</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이력 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[130px]">처리구분</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">상대처</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 6 : 7} className="text-center text-gray-500 py-8">등록된 이력이 없습니다</TableCell>
|
||||
</TableRow>
|
||||
) : formData.installments.map((inst, idx) => (
|
||||
<TableRow key={inst.id} className={inst.type === 'splitEndorsement' ? 'bg-amber-50/50' : ''}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker value={inst.date} onChange={(d) => onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={inst.type} onValueChange={(v) => onUpdateInstallment(inst.id, 'type', v)} disabled={isViewMode}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HISTORY_TYPE_OPTIONS
|
||||
.filter(o => o.value !== 'splitEndorsement' || formData.isSplit)
|
||||
.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput value={inst.amount} onChange={(v) => onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.counterparty} onChange={(e) => onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.note} onChange={(e) => onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => onRemoveInstallment(inst.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { RECOURSE_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function RecourseSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">소구 (상환) 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">배서양도한 어음이 부도나 피배서인이 소구권을 행사하여 상환을 요구한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>소구일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.recourseDate} onChange={(d) => updateField('recourseDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.recourseAmount} onChange={(v) => updateField('recourseAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구대상 (청구인)</Label>
|
||||
<Input value={formData.recourseTarget} onChange={(e) => updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구사유</Label>
|
||||
<Select value={formData.recourseReason} onValueChange={(v) => updateField('recourseReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECOURSE_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { RENEWAL_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function RenewalSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-amber-200 bg-amber-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
|
||||
개서 정보
|
||||
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700 bg-amber-50">만기연장</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>개서일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.renewalDate} onChange={(d) => updateField('renewalDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>신어음번호</Label>
|
||||
<Input value={formData.renewalNewBillNo} onChange={(e) => updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>개서 사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.renewalReason} onValueChange={(v) => updateField('renewalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RENEWAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { BasicInfoSection } from './BasicInfoSection';
|
||||
export { ElectronicBillSection } from './ElectronicBillSection';
|
||||
export { ExchangeBillSection } from './ExchangeBillSection';
|
||||
export { DiscountInfoSection } from './DiscountInfoSection';
|
||||
export { EndorsementSection } from './EndorsementSection';
|
||||
export { CollectionSection } from './CollectionSection';
|
||||
export { HistorySection } from './HistorySection';
|
||||
export { RenewalSection } from './RenewalSection';
|
||||
export { RecourseSection } from './RecourseSection';
|
||||
export { BuybackSection } from './BuybackSection';
|
||||
export { DishonoredSection } from './DishonoredSection';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { BillFormData } from '../types';
|
||||
|
||||
export interface SectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
}
|
||||
@@ -174,8 +174,10 @@ export function getBillStatusOptions(billType: BillType) {
|
||||
export interface BillApiInstallment {
|
||||
id: number;
|
||||
bill_id: number;
|
||||
type?: string;
|
||||
installment_date: string;
|
||||
amount: string;
|
||||
counterparty?: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -190,7 +192,7 @@ export interface BillApiData {
|
||||
client_name: string | null;
|
||||
amount: string;
|
||||
issue_date: string;
|
||||
maturity_date: string;
|
||||
maturity_date: string | null;
|
||||
status: BillStatus;
|
||||
reason: string | null;
|
||||
installment_count: number;
|
||||
@@ -211,6 +213,58 @@ export interface BillApiData {
|
||||
account_name: string;
|
||||
} | null;
|
||||
installments?: BillApiInstallment[];
|
||||
// V8 확장 필드
|
||||
instrument_type?: string;
|
||||
medium?: string;
|
||||
bill_category?: string;
|
||||
electronic_bill_no?: string | null;
|
||||
registration_org?: string | null;
|
||||
drawee?: string | null;
|
||||
acceptance_status?: string | null;
|
||||
acceptance_date?: string | null;
|
||||
acceptance_refusal_date?: string | null;
|
||||
acceptance_refusal_reason?: string | null;
|
||||
endorsement?: string | null;
|
||||
endorsement_order?: string | null;
|
||||
storage_place?: string | null;
|
||||
issuer_bank?: string | null;
|
||||
is_discounted?: boolean;
|
||||
discount_date?: string | null;
|
||||
discount_bank?: string | null;
|
||||
discount_rate?: string | null;
|
||||
discount_amount?: string | null;
|
||||
endorsement_date?: string | null;
|
||||
endorsee?: string | null;
|
||||
endorsement_reason?: string | null;
|
||||
collection_bank?: string | null;
|
||||
collection_request_date?: string | null;
|
||||
collection_fee?: string | null;
|
||||
collection_complete_date?: string | null;
|
||||
collection_result?: string | null;
|
||||
collection_deposit_date?: string | null;
|
||||
collection_deposit_amount?: string | null;
|
||||
settlement_bank?: string | null;
|
||||
payment_method?: string | null;
|
||||
actual_payment_date?: string | null;
|
||||
payment_place?: string | null;
|
||||
payment_place_detail?: string | null;
|
||||
renewal_date?: string | null;
|
||||
renewal_new_bill_no?: string | null;
|
||||
renewal_reason?: string | null;
|
||||
recourse_date?: string | null;
|
||||
recourse_amount?: string | null;
|
||||
recourse_target?: string | null;
|
||||
recourse_reason?: string | null;
|
||||
buyback_date?: string | null;
|
||||
buyback_amount?: string | null;
|
||||
buyback_bank?: string | null;
|
||||
dishonored_date?: string | null;
|
||||
dishonored_reason?: string | null;
|
||||
has_protest?: boolean;
|
||||
protest_date?: string | null;
|
||||
recourse_notice_date?: string | null;
|
||||
recourse_notice_deadline?: string | null;
|
||||
is_split?: boolean;
|
||||
}
|
||||
|
||||
export interface BillApiResponse {
|
||||
@@ -235,7 +289,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
|
||||
vendorName: apiData.client?.name || apiData.client_name || '',
|
||||
amount: parseFloat(apiData.amount),
|
||||
issueDate: apiData.issue_date,
|
||||
maturityDate: apiData.maturity_date,
|
||||
maturityDate: apiData.maturity_date || '',
|
||||
status: apiData.status,
|
||||
reason: apiData.reason || '',
|
||||
installmentCount: apiData.installment_count,
|
||||
@@ -251,7 +305,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 함수 =====
|
||||
// ===== Frontend → API 변환 함수 (V8 전체 필드 전송) =====
|
||||
export function transformFrontendToApi(data: Partial<BillRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
@@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
if (data.vendorName !== undefined) result.client_name = data.vendorName || null;
|
||||
if (data.amount !== undefined) result.amount = data.amount;
|
||||
if (data.issueDate !== undefined) result.issue_date = data.issueDate;
|
||||
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate;
|
||||
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate || null;
|
||||
if (data.status !== undefined) result.status = data.status;
|
||||
if (data.reason !== undefined) result.reason = data.reason || null;
|
||||
if (data.note !== undefined) result.note = data.note || null;
|
||||
@@ -275,4 +329,334 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== BillFormData → API payload 변환 (V8 전체 필드 전송) =====
|
||||
export function transformFormDataToApi(data: BillFormData, vendorName: string): Record<string, unknown> {
|
||||
const isReceived = data.direction === 'received';
|
||||
const orNull = (v: string) => v || null;
|
||||
const orNullNum = (v: number) => v || null;
|
||||
const orNullDate = (v: string) => v || null;
|
||||
|
||||
return {
|
||||
// 기존 12개 필드
|
||||
bill_number: data.billNumber,
|
||||
bill_type: data.direction,
|
||||
client_id: isReceived ? (data.vendor ? parseInt(data.vendor) : null) : (data.payee ? parseInt(data.payee) : null),
|
||||
client_name: vendorName || null,
|
||||
amount: data.amount,
|
||||
issue_date: data.issueDate,
|
||||
maturity_date: orNullDate(data.maturityDate),
|
||||
status: isReceived ? data.receivedStatus : data.issuedStatus,
|
||||
note: orNull(data.note),
|
||||
is_electronic: data.medium === 'electronic',
|
||||
// V8 확장 필드
|
||||
instrument_type: data.instrumentType,
|
||||
medium: data.medium,
|
||||
bill_category: orNull(data.billCategory),
|
||||
electronic_bill_no: orNull(data.electronicBillNo),
|
||||
registration_org: orNull(data.registrationOrg),
|
||||
drawee: orNull(data.drawee),
|
||||
acceptance_status: orNull(data.acceptanceStatus),
|
||||
acceptance_date: orNullDate(data.acceptanceDate),
|
||||
acceptance_refusal_date: orNullDate(data.acceptanceRefusalDate),
|
||||
acceptance_refusal_reason: orNull(data.acceptanceRefusalReason),
|
||||
endorsement: orNull(data.endorsement),
|
||||
endorsement_order: orNull(data.endorsementOrder),
|
||||
storage_place: orNull(data.storagePlace),
|
||||
issuer_bank: orNull(data.issuerBank),
|
||||
is_discounted: data.isDiscounted,
|
||||
discount_date: orNullDate(data.discountDate),
|
||||
discount_bank: orNull(data.discountBank),
|
||||
discount_rate: orNullNum(data.discountRate),
|
||||
discount_amount: orNullNum(data.discountAmount),
|
||||
endorsement_date: orNullDate(data.endorsementDate),
|
||||
endorsee: orNull(data.endorsee),
|
||||
endorsement_reason: orNull(data.endorsementReason),
|
||||
collection_bank: orNull(data.collectionBank),
|
||||
collection_request_date: orNullDate(data.collectionRequestDate),
|
||||
collection_fee: orNullNum(data.collectionFee),
|
||||
collection_complete_date: orNullDate(data.collectionCompleteDate),
|
||||
collection_result: orNull(data.collectionResult),
|
||||
collection_deposit_date: orNullDate(data.collectionDepositDate),
|
||||
collection_deposit_amount: orNullNum(data.collectionDepositAmount),
|
||||
settlement_bank: orNull(data.settlementBank),
|
||||
payment_method: orNull(data.paymentMethod),
|
||||
actual_payment_date: orNullDate(data.actualPaymentDate),
|
||||
payment_place: orNull(data.paymentPlace),
|
||||
payment_place_detail: orNull(data.paymentPlaceDetail),
|
||||
renewal_date: orNullDate(data.renewalDate),
|
||||
renewal_new_bill_no: orNull(data.renewalNewBillNo),
|
||||
renewal_reason: orNull(data.renewalReason),
|
||||
recourse_date: orNullDate(data.recourseDate),
|
||||
recourse_amount: orNullNum(data.recourseAmount),
|
||||
recourse_target: orNull(data.recourseTarget),
|
||||
recourse_reason: orNull(data.recourseReason),
|
||||
buyback_date: orNullDate(data.buybackDate),
|
||||
buyback_amount: orNullNum(data.buybackAmount),
|
||||
buyback_bank: orNull(data.buybackBank),
|
||||
dishonored_date: orNullDate(data.dishonoredDate),
|
||||
dishonored_reason: orNull(data.dishonoredReason),
|
||||
has_protest: data.hasProtest,
|
||||
protest_date: orNullDate(data.protestDate),
|
||||
recourse_notice_date: orNullDate(data.recourseNoticeDate),
|
||||
recourse_notice_deadline: orNullDate(data.recourseNoticeDeadline),
|
||||
is_split: data.isSplit,
|
||||
// 이력(차수)
|
||||
installments: data.installments.map(inst => ({
|
||||
date: inst.date,
|
||||
type: inst.type || 'other',
|
||||
amount: inst.amount,
|
||||
counterparty: orNull(inst.counterparty),
|
||||
note: orNull(inst.note),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// V8 확장 타입 (프로토타입 → 실제 페이지 마이그레이션)
|
||||
// =============================================
|
||||
|
||||
// ===== 증권종류 =====
|
||||
export type InstrumentType = 'promissory' | 'exchange' | 'cashierCheck' | 'currentCheck';
|
||||
|
||||
// ===== 거래방향 (Direction = BillType alias) =====
|
||||
export type Direction = 'received' | 'issued';
|
||||
|
||||
// ===== 매체 =====
|
||||
export type Medium = 'electronic' | 'paper';
|
||||
|
||||
// ===== 이력 레코드 (V8: 처리구분/상대처 추가) =====
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
type: string; // 처리구분 (HISTORY_TYPE_OPTIONS)
|
||||
amount: number;
|
||||
counterparty: string; // 상대처
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ===== V8 폼 데이터 (전체 ~45개 필드) =====
|
||||
export interface BillFormData {
|
||||
// === 공통 ===
|
||||
billNumber: string;
|
||||
instrumentType: InstrumentType;
|
||||
direction: Direction;
|
||||
medium: Medium;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
note: string;
|
||||
// === 전자어음 (조건: medium=electronic) ===
|
||||
electronicBillNo: string;
|
||||
registrationOrg: string;
|
||||
// === 환어음 (조건: instrumentType=exchange) ===
|
||||
drawee: string;
|
||||
acceptanceStatus: string;
|
||||
acceptanceDate: string;
|
||||
// === 받을어음 전용 ===
|
||||
vendor: string;
|
||||
billCategory: string;
|
||||
issuerBank: string;
|
||||
endorsement: string;
|
||||
endorsementOrder: string;
|
||||
storagePlace: string;
|
||||
receivedStatus: string;
|
||||
isDiscounted: boolean;
|
||||
discountDate: string;
|
||||
discountBank: string;
|
||||
discountRate: number;
|
||||
discountAmount: number;
|
||||
// 배서양도
|
||||
endorsementDate: string;
|
||||
endorsee: string;
|
||||
endorsementReason: string;
|
||||
// 추심
|
||||
collectionBank: string;
|
||||
collectionRequestDate: string;
|
||||
collectionFee: number;
|
||||
collectionCompleteDate: string;
|
||||
collectionResult: string;
|
||||
collectionDepositDate: string;
|
||||
collectionDepositAmount: number;
|
||||
// === 지급어음 전용 ===
|
||||
payee: string;
|
||||
settlementBank: string;
|
||||
paymentMethod: string;
|
||||
issuedStatus: string;
|
||||
actualPaymentDate: string;
|
||||
// === 공통 ===
|
||||
paymentPlace: string;
|
||||
paymentPlaceDetail: string;
|
||||
// === 개서 ===
|
||||
renewalDate: string;
|
||||
renewalNewBillNo: string;
|
||||
renewalReason: string;
|
||||
// === 소구/환매 ===
|
||||
recourseDate: string;
|
||||
recourseAmount: number;
|
||||
recourseTarget: string;
|
||||
recourseReason: string;
|
||||
buybackDate: string;
|
||||
buybackAmount: number;
|
||||
buybackBank: string;
|
||||
// === 환어음 인수거절 ===
|
||||
acceptanceRefusalDate: string;
|
||||
acceptanceRefusalReason: string;
|
||||
// === 공통 조건부 ===
|
||||
isSplit: boolean;
|
||||
splitCount: number;
|
||||
splitAmount: number;
|
||||
dishonoredDate: string;
|
||||
dishonoredReason: string;
|
||||
// 부도 법적 프로세스
|
||||
hasProtest: boolean;
|
||||
protestDate: string;
|
||||
recourseNoticeDate: string;
|
||||
recourseNoticeDeadline: string;
|
||||
// === 이력 관리 ===
|
||||
installments: HistoryRecord[];
|
||||
// === 입출금 계좌 ===
|
||||
bankAccountInfo: string;
|
||||
}
|
||||
|
||||
// ===== 초기 폼 데이터 =====
|
||||
export const INITIAL_BILL_FORM_DATA: BillFormData = {
|
||||
billNumber: '', instrumentType: 'promissory', direction: 'received',
|
||||
medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '',
|
||||
electronicBillNo: '', registrationOrg: '',
|
||||
drawee: '', acceptanceStatus: '', acceptanceDate: '',
|
||||
vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1',
|
||||
storagePlace: '', receivedStatus: 'stored', isDiscounted: false,
|
||||
discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0,
|
||||
endorsementDate: '', endorsee: '', endorsementReason: '',
|
||||
collectionBank: '', collectionRequestDate: '', collectionFee: 0,
|
||||
collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0,
|
||||
payee: '', settlementBank: '', paymentMethod: 'autoTransfer',
|
||||
issuedStatus: 'stored', actualPaymentDate: '',
|
||||
paymentPlace: '', paymentPlaceDetail: '',
|
||||
renewalDate: '', renewalNewBillNo: '', renewalReason: '',
|
||||
recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '',
|
||||
buybackDate: '', buybackAmount: 0, buybackBank: '',
|
||||
acceptanceRefusalDate: '', acceptanceRefusalReason: '',
|
||||
isSplit: false, splitCount: 0, splitAmount: 0,
|
||||
dishonoredDate: '', dishonoredReason: '',
|
||||
hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '',
|
||||
installments: [], bankAccountInfo: '',
|
||||
};
|
||||
|
||||
// ===== BillApiData → BillFormData 직접 변환 (V8 전체 필드 매핑) =====
|
||||
export function apiDataToFormData(apiData: BillApiData): BillFormData {
|
||||
const pf = (v: string | null | undefined) => v ? parseFloat(v) : 0;
|
||||
|
||||
return {
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
billNumber: apiData.bill_number,
|
||||
instrumentType: (apiData.instrument_type as InstrumentType) || 'promissory',
|
||||
direction: apiData.bill_type as Direction,
|
||||
medium: (apiData.medium as Medium) || (apiData.is_electronic ? 'electronic' : 'paper'),
|
||||
amount: parseFloat(apiData.amount),
|
||||
issueDate: apiData.issue_date,
|
||||
maturityDate: apiData.maturity_date || '',
|
||||
note: apiData.note || '',
|
||||
// 전자어음
|
||||
electronicBillNo: apiData.electronic_bill_no || '',
|
||||
registrationOrg: apiData.registration_org || '',
|
||||
// 환어음
|
||||
drawee: apiData.drawee || '',
|
||||
acceptanceStatus: apiData.acceptance_status || '',
|
||||
acceptanceDate: apiData.acceptance_date || '',
|
||||
acceptanceRefusalDate: apiData.acceptance_refusal_date || '',
|
||||
acceptanceRefusalReason: apiData.acceptance_refusal_reason || '',
|
||||
// 거래처
|
||||
vendor: apiData.bill_type === 'received' && apiData.client_id ? String(apiData.client_id) : '',
|
||||
payee: apiData.bill_type === 'issued' && apiData.client_id ? String(apiData.client_id) : '',
|
||||
// 받을어음 전용
|
||||
billCategory: apiData.bill_category || 'commercial',
|
||||
issuerBank: apiData.issuer_bank || '',
|
||||
endorsement: apiData.endorsement || 'endorsable',
|
||||
endorsementOrder: apiData.endorsement_order || '1',
|
||||
storagePlace: apiData.storage_place || '',
|
||||
receivedStatus: apiData.bill_type === 'received' ? apiData.status : 'stored',
|
||||
isDiscounted: apiData.is_discounted ?? false,
|
||||
discountDate: apiData.discount_date || '',
|
||||
discountBank: apiData.discount_bank || '',
|
||||
discountRate: pf(apiData.discount_rate),
|
||||
discountAmount: pf(apiData.discount_amount),
|
||||
endorsementDate: apiData.endorsement_date || '',
|
||||
endorsee: apiData.endorsee || '',
|
||||
endorsementReason: apiData.endorsement_reason || '',
|
||||
collectionBank: apiData.collection_bank || '',
|
||||
collectionRequestDate: apiData.collection_request_date || '',
|
||||
collectionFee: pf(apiData.collection_fee),
|
||||
collectionCompleteDate: apiData.collection_complete_date || '',
|
||||
collectionResult: apiData.collection_result || '',
|
||||
collectionDepositDate: apiData.collection_deposit_date || '',
|
||||
collectionDepositAmount: pf(apiData.collection_deposit_amount),
|
||||
// 지급어음 전용
|
||||
settlementBank: apiData.settlement_bank || '',
|
||||
paymentMethod: apiData.payment_method || 'autoTransfer',
|
||||
issuedStatus: apiData.bill_type === 'issued' ? apiData.status : 'stored',
|
||||
actualPaymentDate: apiData.actual_payment_date || '',
|
||||
// 공통
|
||||
paymentPlace: apiData.payment_place || '',
|
||||
paymentPlaceDetail: apiData.payment_place_detail || '',
|
||||
// 개서
|
||||
renewalDate: apiData.renewal_date || '',
|
||||
renewalNewBillNo: apiData.renewal_new_bill_no || '',
|
||||
renewalReason: apiData.renewal_reason || '',
|
||||
// 소구/환매
|
||||
recourseDate: apiData.recourse_date || '',
|
||||
recourseAmount: pf(apiData.recourse_amount),
|
||||
recourseTarget: apiData.recourse_target || '',
|
||||
recourseReason: apiData.recourse_reason || '',
|
||||
buybackDate: apiData.buyback_date || '',
|
||||
buybackAmount: pf(apiData.buyback_amount),
|
||||
buybackBank: apiData.buyback_bank || '',
|
||||
// 부도
|
||||
isSplit: apiData.is_split ?? false,
|
||||
splitCount: 0,
|
||||
splitAmount: 0,
|
||||
dishonoredDate: apiData.dishonored_date || '',
|
||||
dishonoredReason: apiData.dishonored_reason || '',
|
||||
hasProtest: apiData.has_protest ?? false,
|
||||
protestDate: apiData.protest_date || '',
|
||||
recourseNoticeDate: apiData.recourse_notice_date || '',
|
||||
recourseNoticeDeadline: apiData.recourse_notice_deadline || '',
|
||||
// 이력
|
||||
installments: (apiData.installments || []).map(inst => ({
|
||||
id: String(inst.id),
|
||||
date: inst.installment_date,
|
||||
type: inst.type || 'other',
|
||||
amount: parseFloat(inst.amount),
|
||||
counterparty: inst.counterparty || '',
|
||||
note: inst.note || '',
|
||||
})),
|
||||
bankAccountInfo: apiData.bank_account_id ? String(apiData.bank_account_id) : '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== BillRecord → BillFormData 변환 (하위호환 유지) =====
|
||||
export function billRecordToFormData(record: BillRecord): BillFormData {
|
||||
return {
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
billNumber: record.billNumber,
|
||||
direction: record.billType as Direction,
|
||||
amount: record.amount,
|
||||
issueDate: record.issueDate,
|
||||
maturityDate: record.maturityDate,
|
||||
note: record.note,
|
||||
receivedStatus: record.billType === 'received' ? record.status : 'stored',
|
||||
issuedStatus: record.billType === 'issued' ? record.status : 'stored',
|
||||
vendor: record.billType === 'received' ? record.vendorId : '',
|
||||
payee: record.billType === 'issued' ? record.vendorId : '',
|
||||
installments: record.installments.map(inst => ({
|
||||
id: inst.id,
|
||||
date: inst.date,
|
||||
type: 'other',
|
||||
amount: inst.amount,
|
||||
counterparty: '',
|
||||
note: inst.note,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CardTransaction, JournalEntryItem } from './types';
|
||||
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||
import { DEDUCTION_OPTIONS } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { saveJournalEntries } from './actions';
|
||||
|
||||
interface JournalEntryModalProps {
|
||||
@@ -194,23 +195,16 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
|
||||
|
||||
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* Select - FormField 예외 */}
|
||||
<div>
|
||||
<Label className="text-xs">계정과목</Label>
|
||||
<Select
|
||||
value={item.accountSubject || 'none'}
|
||||
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1">
|
||||
<AccountSubjectSelect
|
||||
value={item.accountSubject}
|
||||
onValueChange={(v) => updateItem(index, 'accountSubject', v)}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Select - FormField 예외 */}
|
||||
<div>
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import type { ManualInputFormData } from './types';
|
||||
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||
import { DEDUCTION_OPTIONS } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { getCardList, createCardTransaction } from './actions';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
|
||||
@@ -254,20 +255,13 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">계정과목</Label>
|
||||
<Select
|
||||
value={formData.accountSubject || 'none'}
|
||||
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1">
|
||||
<AccountSubjectSelect
|
||||
value={formData.accountSubject}
|
||||
onValueChange={(v) => handleChange('accountSubject', v)}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
|
||||
import {
|
||||
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
getCardTransactionList,
|
||||
getCardTransactionSummary,
|
||||
@@ -55,6 +56,29 @@ import { JournalEntryModal } from './JournalEntryModal';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { filterByEnum } from '@/lib/utils/search';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
|
||||
// ===== 엑셀 다운로드 컬럼 =====
|
||||
const excelColumns: ExcelColumn<CardTransaction>[] = [
|
||||
{ header: '사용일시', key: 'usedAt', width: 18 },
|
||||
{ header: '카드사', key: 'cardCompany', width: 10 },
|
||||
{ header: '카드번호', key: 'card', width: 12 },
|
||||
{ header: '카드명', key: 'cardName', width: 12 },
|
||||
{ header: '공제', key: 'deductionType', width: 10,
|
||||
transform: (v) => v === 'deductible' ? '공제' : '불공제' },
|
||||
{ header: '사업자번호', key: 'businessNumber', width: 15 },
|
||||
{ header: '가맹점명', key: 'merchantName', width: 15 },
|
||||
{ header: '증빙/판매자상호', key: 'vendorName', width: 18 },
|
||||
{ header: '내역', key: 'description', width: 15 },
|
||||
{ header: '합계금액', key: 'totalAmount', width: 12 },
|
||||
{ header: '공급가액', key: 'supplyAmount', width: 12 },
|
||||
{ header: '세액', key: 'taxAmount', width: 10 },
|
||||
{ header: '계정과목', key: 'accountSubject', width: 12,
|
||||
transform: (v) => {
|
||||
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
|
||||
return found?.label || String(v || '');
|
||||
}},
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
|
||||
const tableColumns = [
|
||||
@@ -66,7 +90,7 @@ const tableColumns = [
|
||||
{ key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false },
|
||||
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' },
|
||||
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' },
|
||||
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false },
|
||||
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', sortable: false },
|
||||
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false },
|
||||
@@ -269,9 +293,45 @@ export function CardTransactionInquiry() {
|
||||
setShowJournalEntry(true);
|
||||
}, []);
|
||||
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.');
|
||||
}, []);
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: CardTransaction[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getCardTransactionList({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel<CardTransaction & Record<string, unknown>>({
|
||||
data: allData as (CardTransaction & Record<string, unknown>)[],
|
||||
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
|
||||
filename: '카드사용내역',
|
||||
sheetName: '카드사용내역',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, searchQuery]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<CardTransaction> = useMemo(
|
||||
@@ -540,20 +600,13 @@ export function CardTransactionInquiry() {
|
||||
</TableCell>
|
||||
{/* 계정과목 (인라인 Select) */}
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
|
||||
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountSubjectSelect
|
||||
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
|
||||
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
className="min-w-[90px] w-auto"
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 분개 버튼 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format, parseISO, subMonths, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react';
|
||||
import { Download, FileText, Loader2, Printer, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -15,18 +15,28 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { printElement } from '@/lib/print-utils';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
// ===== 빠른 월 선택 버튼 정의 =====
|
||||
const QUICK_MONTH_BUTTONS = [
|
||||
{ label: '이번달', months: 0 },
|
||||
{ label: '지난달', months: 1 },
|
||||
{ label: 'D-2월', months: 2 },
|
||||
{ label: 'D-3월', months: 3 },
|
||||
{ label: 'D-4월', months: 4 },
|
||||
{ label: 'D-5월', months: 5 },
|
||||
] as const;
|
||||
|
||||
// ===== Props 인터페이스 =====
|
||||
interface DailyReportProps {
|
||||
initialNoteReceivables?: NoteReceivableItem[];
|
||||
@@ -36,7 +46,9 @@ interface DailyReportProps {
|
||||
export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) {
|
||||
const { canExport } = usePermission();
|
||||
// ===== 상태 관리 =====
|
||||
const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [noteReceivables, setNoteReceivables] = useState<NoteReceivableItem[]>(initialNoteReceivables);
|
||||
const [dailyAccounts, setDailyAccounts] = useState<DailyAccountItem[]>(initialDailyAccounts);
|
||||
const [summary, setSummary] = useState<{
|
||||
@@ -53,9 +65,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [noteResult, accountResult, summaryResult] = await Promise.all([
|
||||
getNoteReceivables({ date: selectedDate }),
|
||||
getDailyAccounts({ date: selectedDate }),
|
||||
getDailyReportSummary({ date: selectedDate }),
|
||||
getNoteReceivables({ date: startDate }),
|
||||
getDailyAccounts({ date: startDate }),
|
||||
getDailyReportSummary({ date: startDate }),
|
||||
]);
|
||||
|
||||
if (noteResult.success) {
|
||||
@@ -81,20 +93,20 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 초기 로드 및 날짜 변경시 재로드 =====
|
||||
const isInitialMount = useRef(true);
|
||||
const prevDateRef = useRef(selectedDate);
|
||||
const prevDateRef = useRef(startDate);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드
|
||||
if (isInitialMount.current || prevDateRef.current !== selectedDate) {
|
||||
if (isInitialMount.current || prevDateRef.current !== startDate) {
|
||||
isInitialMount.current = false;
|
||||
prevDateRef.current = selectedDate;
|
||||
prevDateRef.current = startDate;
|
||||
loadData();
|
||||
}
|
||||
}, [selectedDate, loadData]);
|
||||
}, [startDate, loadData]);
|
||||
|
||||
// ===== 어음 합계 (API 요약 사용) =====
|
||||
const noteReceivableTotal = useMemo(() => {
|
||||
@@ -144,9 +156,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, [accountTotals]);
|
||||
|
||||
// ===== 선택된 날짜 정보 =====
|
||||
const selectedDateInfo = useMemo(() => {
|
||||
const startDateInfo = useMemo(() => {
|
||||
try {
|
||||
const date = parseISO(selectedDate);
|
||||
const date = parseISO(startDate);
|
||||
return {
|
||||
formatted: format(date, 'yyyy년 M월 d일', { locale: ko }),
|
||||
dayOfWeek: format(date, 'EEEE', { locale: ko }),
|
||||
@@ -154,12 +166,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} catch {
|
||||
return { formatted: '', dayOfWeek: '' };
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 엑셀 다운로드 (프록시 API 직접 호출) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const url = `/api/proxy/daily-report/export?date=${selectedDate}`;
|
||||
const url = `/api/proxy/daily-report/export?date=${startDate}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -169,7 +181,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`;
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${startDate}.xlsx`;
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -183,7 +195,55 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 빠른 월 선택 =====
|
||||
const handleQuickMonth = useCallback((monthsAgo: number) => {
|
||||
const target = monthsAgo === 0 ? new Date() : subMonths(new Date(), monthsAgo);
|
||||
setStartDate(format(startOfMonth(target), 'yyyy-MM-dd'));
|
||||
setEndDate(format(endOfMonth(target), 'yyyy-MM-dd'));
|
||||
}, []);
|
||||
|
||||
// ===== 인쇄 =====
|
||||
const printAreaRef = useRef<HTMLDivElement>(null);
|
||||
const handlePrint = useCallback(() => {
|
||||
if (printAreaRef.current) {
|
||||
printElement(printAreaRef.current, {
|
||||
title: `일일일보_${startDate}`,
|
||||
styles: `
|
||||
.print-container { font-size: 11px; }
|
||||
table { width: 100%; margin-bottom: 12px; }
|
||||
h3 { margin-bottom: 8px; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
}, [startDate]);
|
||||
|
||||
// ===== USD 금액 포맷 =====
|
||||
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
|
||||
|
||||
// ===== 검색 필터링 =====
|
||||
const filteredNoteReceivables = useMemo(() => {
|
||||
if (!searchTerm) return noteReceivables;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return noteReceivables.filter(item =>
|
||||
item.content.toLowerCase().includes(term)
|
||||
);
|
||||
}, [noteReceivables, searchTerm]);
|
||||
|
||||
const filteredDailyAccounts = useMemo(() => {
|
||||
if (!searchTerm) return dailyAccounts;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return dailyAccounts.filter(item =>
|
||||
item.category.toLowerCase().includes(term)
|
||||
);
|
||||
}, [dailyAccounts, searchTerm]);
|
||||
|
||||
// ===== USD 데이터 존재 여부 =====
|
||||
const hasUsdAccounts = useMemo(() =>
|
||||
filteredDailyAccounts.some(item => item.currency === 'USD'),
|
||||
[filteredDailyAccounts]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -194,62 +254,81 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (날짜 선택, 버튼 등) */}
|
||||
{/* 헤더 액션 (날짜 선택, 빠른 월 선택, 검색, 인쇄) */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Calendar className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-700 shrink-0">조회 일자</span>
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
className="w-auto min-w-[140px]"
|
||||
size="sm"
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-8 px-2 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
<CardContent className="p-3 md:p-4">
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
{/* DateRange */}
|
||||
<DateRangePicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
size="sm"
|
||||
className="w-full md:w-auto md:min-w-[280px]"
|
||||
displayFormat="yyyy-MM-dd"
|
||||
/>
|
||||
{/* 빠른 월 선택 버튼 - 모바일: 가로 스크롤 */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 overflow-x-auto pb-1 -mb-1">
|
||||
{QUICK_MONTH_BUTTONS.map((btn) => (
|
||||
<Button
|
||||
key={btn.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 md:h-8 px-2 md:px-2.5 text-xs shrink-0"
|
||||
onClick={() => handleQuickMonth(btn.months)}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{/* 검색 + 인쇄/엑셀 - 모바일: 세로 배치 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-[300px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint} className="h-7 md:h-8 px-2 md:px-3 text-xs">
|
||||
<Printer className="mr-1 h-3.5 w-3.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-7 md:h-8 px-2 md:px-3 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
{/* 인쇄 영역 */}
|
||||
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
|
||||
{/* 일자별 입출금 합계 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">
|
||||
일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[550px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
|
||||
<div className="min-w-[420px] md:min-w-[650px]">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold max-w-[200px]">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">만기일</TableHead>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">구분</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">입금</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">출금</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">잔액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -258,129 +337,343 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500">데이터를 불러오는 중...</span>
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : noteReceivables.length === 0 ? (
|
||||
) : filteredDailyAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
noteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[200px] truncate">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{noteReceivables.length > 0 && (
|
||||
<TableFooter>
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 일자별 상세 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
일자: {selectedDateInfo.formatted} {selectedDateInfo.dayOfWeek}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[650px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold max-w-[180px]">구분</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">전월 이월</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">수입</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">지출</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">잔액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dailyAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{/* KRW 계좌들 */}
|
||||
{dailyAccounts
|
||||
{filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[180px] truncate">{item.category}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">
|
||||
<Badge className={MATCH_STATUS_COLORS[item.matchStatus]}>
|
||||
{MATCH_STATUS_LABELS[item.matchStatus]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.balance)}</TableCell>
|
||||
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* KRW 소계 */}
|
||||
{hasUsdAccounts && (
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell className="text-xs md:text-sm font-semibold">원화(KRW) 소계</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.balance)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{/* USD 계좌들 */}
|
||||
{hasUsdAccounts && filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* USD 소계 */}
|
||||
{hasUsdAccounts && (
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell className="text-xs md:text-sm font-semibold">외국환(USD) 소계</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
{dailyAccounts.length > 0 && (
|
||||
{filteredDailyAccounts.length > 0 && (
|
||||
<TableFooter>
|
||||
{/* 외화원 (USD) 합계 */}
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-semibold whitespace-nowrap">외화원 (USD) 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
{/* 현금성 자산 합계 */}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold whitespace-nowrap">현금성 자산 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.carryover)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
<TableCell className="font-bold whitespace-nowrap text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 예금 입출금 내역 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold">예금 입출금 내역</h3>
|
||||
</div>
|
||||
{/* KRW 입출금 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* KRW 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-blue-700 text-sm md:text-base">입금 (KRW)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">입금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
입금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW' && item.income > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`deposit-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">입금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KRW 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-red-700 text-sm md:text-base">출금 (KRW)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">출금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
출금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW' && item.expense > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`withdrawal-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-red-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">출금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USD 입출금 — USD 데이터가 있을 때만 표시 */}
|
||||
{hasUsdAccounts && (
|
||||
<>
|
||||
<div className="flex items-center justify-center mt-4 mb-3 py-1.5 md:py-2 bg-emerald-50 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold text-emerald-800">외국환(USD) 입출금 내역</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* USD 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-emerald-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-emerald-700 text-sm md:text-base">입금 (USD)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">입금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
USD 입금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD' && item.income > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`usd-deposit-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-emerald-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">입금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USD 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-orange-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-orange-700 text-sm md:text-base">출금 (USD)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">출금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
USD 출금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD' && item.expense > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`usd-withdrawal-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-orange-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">출금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.expense)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto max-h-[25vh] md:max-h-[30vh] overflow-y-auto">
|
||||
<div className="min-w-[480px] md:min-w-[550px]">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">만기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredNoteReceivables.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredNoteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{filteredNoteReceivables.length > 0 && (
|
||||
<TableFooter className="sticky bottom-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getBankAccounts,
|
||||
} from './actions';
|
||||
import { useDevFill, generateDepositData } from '@/components/dev';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== Props =====
|
||||
interface DepositDetailClientV2Props {
|
||||
@@ -81,14 +82,17 @@ export default function DepositDetailClientV2({
|
||||
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
|
||||
|
||||
if (result.success && mode === 'create') {
|
||||
invalidateDashboard('deposit');
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/deposits');
|
||||
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
|
||||
}
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
[mode, depositId, router]
|
||||
);
|
||||
@@ -98,9 +102,11 @@ export default function DepositDetailClientV2({
|
||||
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
|
||||
|
||||
const result = await deleteDeposit(depositId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}, [depositId]);
|
||||
|
||||
// ===== 모드 변경 핸들러 =====
|
||||
|
||||
@@ -73,6 +73,7 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
|
||||
import { toast } from 'sonner';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
extractUniqueOptions,
|
||||
@@ -225,6 +226,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteDeposit(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
toast.success('입금 내역이 삭제되었습니다.');
|
||||
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
|
||||
await handleRefresh();
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function getClients(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
|
||||
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import {
|
||||
Receipt,
|
||||
Calendar as CalendarIcon,
|
||||
@@ -88,8 +89,8 @@ import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
TRANSACTION_TYPE_FILTER_OPTIONS,
|
||||
PAYMENT_STATUS_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { extractUniqueOptions } from '../shared';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
|
||||
@@ -247,6 +248,7 @@ export function ExpectedExpenseManagement({
|
||||
// 수정
|
||||
const result = await updateExpectedExpense(editingItem.id, formData);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
|
||||
toast.success('미지급비용이 수정되었습니다.');
|
||||
setShowFormDialog(false);
|
||||
@@ -258,6 +260,7 @@ export function ExpectedExpenseManagement({
|
||||
// 등록
|
||||
const result = await createExpectedExpense(formData);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => [result.data!, ...prev]);
|
||||
toast.success('미지급비용이 등록되었습니다.');
|
||||
setShowFormDialog(false);
|
||||
@@ -278,6 +281,7 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await deleteExpectedExpenses(selectedIds);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
|
||||
@@ -492,6 +496,7 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await deleteExpectedExpense(deleteTargetId);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.filter(item => item.id !== deleteTargetId));
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -522,6 +527,7 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.map(item =>
|
||||
selectedItems.has(item.id)
|
||||
? { ...item, expectedPaymentDate: newExpectedDate }
|
||||
@@ -1185,21 +1191,12 @@ export function ExpectedExpenseManagement({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>계정과목</Label>
|
||||
<Select
|
||||
value={formData.accountSubject}
|
||||
<AccountSubjectSelect
|
||||
value={formData.accountSubject || ''}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="계정과목 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder="계정과목 선택"
|
||||
category="expense"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -56,14 +57,12 @@ import {
|
||||
getJournalDetail,
|
||||
updateJournalDetail,
|
||||
deleteJournalDetail,
|
||||
getAccountSubjects,
|
||||
getVendorList,
|
||||
} from './actions';
|
||||
import type {
|
||||
GeneralJournalRecord,
|
||||
JournalEntryRow,
|
||||
JournalSide,
|
||||
AccountSubject,
|
||||
VendorOption,
|
||||
} from './types';
|
||||
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
|
||||
@@ -109,7 +108,6 @@ export function JournalEditModal({
|
||||
const [accountNumber, setAccountNumber] = useState('');
|
||||
|
||||
// 옵션 데이터
|
||||
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
@@ -119,15 +117,11 @@ export function JournalEditModal({
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
|
||||
const [detailRes, vendorsRes] = await Promise.all([
|
||||
getJournalDetail(record.id),
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]);
|
||||
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
if (vendorsRes.success && vendorsRes.data) {
|
||||
setVendors(vendorsRes.data);
|
||||
}
|
||||
@@ -361,24 +355,14 @@ export function JournalEditModal({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubjectId}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
handleRowChange(row.id, 'accountSubjectId', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
size="sm"
|
||||
placeholder="선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
|
||||
@@ -42,8 +42,9 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
|
||||
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { createManualJournal, getVendorList } from './actions';
|
||||
import type { JournalEntryRow, JournalSide, VendorOption } from './types';
|
||||
import { JOURNAL_SIDE_OPTIONS } from './types';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
|
||||
@@ -81,7 +82,6 @@ export function ManualJournalEntryModal({
|
||||
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||
|
||||
// 옵션 데이터
|
||||
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -94,13 +94,7 @@ export function ManualJournalEntryModal({
|
||||
setDescription('');
|
||||
setRows([createEmptyRow()]);
|
||||
|
||||
Promise.all([
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]).then(([subjectsRes, vendorsRes]) => {
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
getVendorList().then((vendorsRes) => {
|
||||
if (vendorsRes.success && vendorsRes.data) {
|
||||
setVendors(vendorsRes.data);
|
||||
}
|
||||
@@ -272,24 +266,14 @@ export function ManualJournalEntryModal({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubjectId}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
handleRowChange(row.id, 'accountSubjectId', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
size="sm"
|
||||
placeholder="선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
|
||||
@@ -8,69 +8,14 @@ import type {
|
||||
GeneralJournalApiData,
|
||||
GeneralJournalSummary,
|
||||
GeneralJournalSummaryApiData,
|
||||
AccountSubject,
|
||||
AccountSubjectApiData,
|
||||
JournalEntryRow,
|
||||
VendorOption,
|
||||
} from './types';
|
||||
import {
|
||||
transformApiToFrontend,
|
||||
transformSummaryApi,
|
||||
transformAccountSubjectApi,
|
||||
} from './types';
|
||||
|
||||
// ===== Mock 데이터 (개발용) =====
|
||||
function generateMockJournalData(): GeneralJournalRecord[] {
|
||||
const descriptions = ['사무용품 구매', '직원 급여', '임대료 지급', '매출 입금', '교통비'];
|
||||
const journalDescs = ['복리후생비', '급여', '임차료', '매출', '여비교통비'];
|
||||
const divisions: Array<'deposit' | 'withdrawal' | 'transfer'> = ['deposit', 'withdrawal', 'transfer'];
|
||||
const sources: Array<'manual' | 'linked'> = ['manual', 'linked'];
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const division = divisions[i % 3];
|
||||
const depositAmount = division === 'deposit' ? 100000 * (i + 1) : 0;
|
||||
const withdrawalAmount = division === 'withdrawal' ? 80000 * (i + 1) : 0;
|
||||
return {
|
||||
id: String(5000 + i),
|
||||
date: '2025-12-12',
|
||||
division,
|
||||
amount: depositAmount || withdrawalAmount || 50000,
|
||||
description: descriptions[i % 5],
|
||||
journalDescription: journalDescs[i % 5],
|
||||
depositAmount,
|
||||
withdrawalAmount,
|
||||
balance: 1000000 - (i * 50000),
|
||||
debitAmount: [6000, 100000, 50000, 0, 30000][i % 5],
|
||||
creditAmount: [0, 0, 50000, 100000, 0][i % 5],
|
||||
source: sources[i % 4 === 0 ? 0 : 1],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateMockSummary(): GeneralJournalSummary {
|
||||
return { totalCount: 10, depositCount: 4, depositAmount: 400000, withdrawalCount: 3, withdrawalAmount: 300000, journalCompleteCount: 7, journalIncompleteCount: 3 };
|
||||
}
|
||||
|
||||
function generateMockAccountSubjects(): AccountSubject[] {
|
||||
return [
|
||||
{ id: '101', code: '1010', name: '현금', category: 'asset', isActive: true },
|
||||
{ id: '102', code: '1020', name: '보통예금', category: 'asset', isActive: true },
|
||||
{ id: '201', code: '2010', name: '미지급금', category: 'liability', isActive: true },
|
||||
{ id: '401', code: '4010', name: '매출', category: 'revenue', isActive: true },
|
||||
{ id: '501', code: '5010', name: '복리후생비', category: 'expense', isActive: true },
|
||||
];
|
||||
}
|
||||
|
||||
function generateMockVendors(): VendorOption[] {
|
||||
return [
|
||||
{ id: '1', name: '삼성전자' },
|
||||
{ id: '2', name: '(주)한국물류' },
|
||||
{ id: '3', name: 'LG전자' },
|
||||
{ id: '4', name: '현대모비스' },
|
||||
{ id: '5', name: '(주)대한상사' },
|
||||
];
|
||||
}
|
||||
|
||||
// ===== 전표 목록 조회 =====
|
||||
export async function getJournalEntries(params: {
|
||||
startDate?: string;
|
||||
@@ -91,15 +36,6 @@ export async function getJournalEntries(params: {
|
||||
errorMessage: '전표 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || result.data.length === 0) {
|
||||
const mockData = generateMockJournalData();
|
||||
return {
|
||||
success: true as const,
|
||||
data: mockData,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -119,10 +55,6 @@ export async function getJournalSummary(params: {
|
||||
errorMessage: '전표 요약 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data) {
|
||||
return { success: true, data: generateMockSummary() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -151,67 +83,6 @@ export async function createManualJournal(data: {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 목록 조회 =====
|
||||
export async function getAccountSubjects(params?: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
}): Promise<ActionResult<AccountSubject[]>> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects', {
|
||||
search: params?.search || undefined,
|
||||
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||
}),
|
||||
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
|
||||
errorMessage: '계정과목 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
return { success: true, data: generateMockAccountSubjects() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 계정과목 추가 =====
|
||||
export async function createAccountSubject(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
},
|
||||
errorMessage: '계정과목 추가에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 상태 토글 =====
|
||||
export async function updateAccountSubjectStatus(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { is_active: isActive },
|
||||
errorMessage: '계정과목 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 삭제 =====
|
||||
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '계정과목 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 분개 상세 조회 =====
|
||||
type JournalDetailData = {
|
||||
id: number;
|
||||
@@ -241,26 +112,6 @@ export async function getJournalDetail(id: string): Promise<ActionResult<Journal
|
||||
errorMessage: '분개 상세 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: Number(id),
|
||||
date: '2025-12-12',
|
||||
division: 'deposit',
|
||||
amount: 100000,
|
||||
description: '사무용품 구매',
|
||||
bank_name: '신한은행',
|
||||
account_number: '110-123-456789',
|
||||
journal_memo: '',
|
||||
rows: [
|
||||
{ id: 1, side: 'debit', account_subject_id: 501, account_subject_name: '복리후생비', vendor_id: 1, vendor_name: '삼성전자', debit_amount: 100000, credit_amount: 0, memo: '' },
|
||||
{ id: 2, side: 'credit', account_subject_id: 101, account_subject_name: '현금', vendor_id: null, vendor_name: '', debit_amount: 0, credit_amount: 100000, memo: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -308,9 +159,5 @@ export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
|
||||
errorMessage: '거래처 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
return { success: true, data: generateMockVendors() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||
import { getJournalEntries, getJournalSummary } from './actions';
|
||||
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
|
||||
import { AccountSubjectSettingModal } from '@/components/accounting/common';
|
||||
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
|
||||
import { JournalEditModal } from './JournalEditModal';
|
||||
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
getPeriodDates,
|
||||
} from './types';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== 테이블 컬럼 (기획서 기준 10개) =====
|
||||
const tableColumns = [
|
||||
@@ -151,12 +152,14 @@ export function GeneralJournalEntry() {
|
||||
const handleManualEntrySuccess = useCallback(() => {
|
||||
setShowManualEntry(false);
|
||||
loadData();
|
||||
invalidateDashboard('journalEntry');
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 분개 수정 완료 =====
|
||||
const handleJournalEditSuccess = useCallback(() => {
|
||||
setJournalEditTarget(null);
|
||||
loadData();
|
||||
invalidateDashboard('journalEntry');
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 합계 계산 =====
|
||||
|
||||
@@ -34,30 +34,6 @@ export const PERIOD_BUTTONS = [
|
||||
|
||||
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
|
||||
|
||||
// ===== 계정과목 분류 =====
|
||||
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
|
||||
|
||||
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
|
||||
{ value: 'asset', label: '자산' },
|
||||
{ value: 'liability', label: '부채' },
|
||||
{ value: 'capital', label: '자본' },
|
||||
{ value: 'revenue', label: '수익' },
|
||||
{ value: 'expense', label: '비용' },
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
...ACCOUNT_CATEGORY_OPTIONS,
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
|
||||
asset: '자산',
|
||||
liability: '부채',
|
||||
capital: '자본',
|
||||
revenue: '수익',
|
||||
expense: '비용',
|
||||
};
|
||||
|
||||
// ===== 분개 구분 (차변/대변) =====
|
||||
export type JournalSide = 'debit' | 'credit';
|
||||
|
||||
@@ -121,25 +97,6 @@ export interface GeneralJournalSummaryApiData {
|
||||
journal_incomplete_count?: number;
|
||||
}
|
||||
|
||||
// ===== 계정과목 =====
|
||||
export interface AccountSubject {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: AccountSubjectCategory;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSubjectApiData {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
is_active: boolean | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== 분개 행 =====
|
||||
export interface JournalEntryRow {
|
||||
id: string;
|
||||
@@ -216,17 +173,6 @@ export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): Gene
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 계정과목 API → Frontend 변환 =====
|
||||
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
code: apiData.code,
|
||||
name: apiData.name,
|
||||
category: apiData.category as AccountSubjectCategory,
|
||||
isActive: Boolean(apiData.is_active),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 기간 버튼 → 날짜 변환 =====
|
||||
export function getPeriodDates(period: PeriodButtonValue): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
updateGiftCertificate,
|
||||
deleteGiftCertificate,
|
||||
} from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import {
|
||||
PURCHASE_PURPOSE_OPTIONS,
|
||||
ENTERTAINMENT_EXPENSE_OPTIONS,
|
||||
@@ -80,6 +81,7 @@ export function GiftCertificateDetail({
|
||||
: await updateGiftCertificate(id!, formData);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
|
||||
router.push('/ko/accounting/gift-certificates');
|
||||
} else {
|
||||
@@ -96,6 +98,7 @@ export function GiftCertificateDetail({
|
||||
try {
|
||||
const result = await deleteGiftCertificate(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
toast.success('상품권이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/gift-certificates');
|
||||
} else {
|
||||
@@ -134,8 +137,8 @@ export function GiftCertificateDetail({
|
||||
label="일련번호"
|
||||
value={formData.serialNumber}
|
||||
onChange={(v) => handleChange('serialNumber', v)}
|
||||
placeholder="자동 생성"
|
||||
disabled={!isNew}
|
||||
placeholder="일련번호를 입력하세요"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="상품권명"
|
||||
|
||||
@@ -1,144 +1,106 @@
|
||||
/**
|
||||
* 상품권 관리 서버 액션 (Mock)
|
||||
* 상품권 관리 서버 액션
|
||||
*
|
||||
* API Endpoints (예정):
|
||||
* - GET /api/v1/gift-certificates - 목록 조회
|
||||
* - GET /api/v1/gift-certificates/{id} - 상세 조회
|
||||
* - POST /api/v1/gift-certificates - 등록
|
||||
* - PUT /api/v1/gift-certificates/{id} - 수정
|
||||
* - DELETE /api/v1/gift-certificates/{id} - 삭제
|
||||
* - GET /api/v1/gift-certificates/summary - 요약 통계
|
||||
* API Endpoints (Loan API 재사용, category='gift_certificate'):
|
||||
* - GET /api/v1/loans?category=gift_certificate - 목록 조회
|
||||
* - GET /api/v1/loans/{id} - 상세 조회
|
||||
* - POST /api/v1/loans - 등록
|
||||
* - PUT /api/v1/loans/{id} - 수정
|
||||
* - DELETE /api/v1/loans/{id} - 삭제
|
||||
* - GET /api/v1/loans/summary?category=gift_certificate - 요약 통계
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import type { ActionResult } from '@/lib/api/execute-server-action';
|
||||
// import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
// import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
// import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction, type PaginatedActionResult } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
GiftCertificateRecord,
|
||||
GiftCertificateFormData,
|
||||
LoanApiData,
|
||||
} from './types';
|
||||
import {
|
||||
transformApiToRecord,
|
||||
transformApiToFormData,
|
||||
transformFormToApi,
|
||||
} from './types';
|
||||
|
||||
// ===== 상품권 목록 조회 (Mock) =====
|
||||
export async function getGiftCertificates(_params?: {
|
||||
// ===== 상품권 목록 조회 =====
|
||||
export async function getGiftCertificates(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
}): Promise<ActionResult<GiftCertificateRecord[]>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: true, data: [] };
|
||||
search?: string;
|
||||
}): Promise<PaginatedActionResult<GiftCertificateRecord>> {
|
||||
return executePaginatedAction<LoanApiData, GiftCertificateRecord>({
|
||||
url: buildApiUrl('/api/v1/loans', {
|
||||
category: 'gift_certificate',
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
search: params?.search,
|
||||
}),
|
||||
transform: transformApiToRecord,
|
||||
errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 상세 조회 (Mock) =====
|
||||
// ===== 상품권 상세 조회 =====
|
||||
export async function getGiftCertificateById(
|
||||
_id: string
|
||||
id: string
|
||||
): Promise<ActionResult<GiftCertificateFormData>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// transform: transformDetailApiToFrontend,
|
||||
// errorMessage: '상품권 조회에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
serialNumber: 'GC-2026-001',
|
||||
name: '신세계 상품권',
|
||||
faceValue: 500000,
|
||||
vendorId: '',
|
||||
vendorName: '신세계백화점',
|
||||
purchaseDate: '2026-02-10',
|
||||
purchasePurpose: 'entertainment',
|
||||
entertainmentExpense: 'applicable',
|
||||
status: 'used',
|
||||
usedDate: '2026-02-20',
|
||||
recipientName: '홍길동',
|
||||
recipientOrganization: '(주)테크솔루션',
|
||||
usageDescription: '거래처 접대용',
|
||||
memo: '2월 접대비 처리 완료',
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
transform: (data: LoanApiData) => transformApiToFormData(data),
|
||||
errorMessage: '상품권 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 등록 (Mock) =====
|
||||
// ===== 상품권 등록 =====
|
||||
export async function createGiftCertificate(
|
||||
_data: GiftCertificateFormData
|
||||
data: GiftCertificateFormData
|
||||
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates'),
|
||||
// method: 'POST',
|
||||
// body: transformFrontendToApi(data),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 등록에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
serialNumber: _data.serialNumber || `GC-${Date.now()}`,
|
||||
name: _data.name,
|
||||
faceValue: _data.faceValue,
|
||||
purchaseDate: _data.purchaseDate,
|
||||
usedDate: _data.usedDate || null,
|
||||
status: _data.status,
|
||||
entertainmentExpense: _data.entertainmentExpense,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/loans'),
|
||||
method: 'POST',
|
||||
body: transformFormToApi(data),
|
||||
transform: (d: LoanApiData) => transformApiToRecord(d),
|
||||
errorMessage: '상품권 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 수정 (Mock) =====
|
||||
// ===== 상품권 수정 =====
|
||||
export async function updateGiftCertificate(
|
||||
_id: string,
|
||||
_data: GiftCertificateFormData
|
||||
id: string,
|
||||
data: GiftCertificateFormData
|
||||
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// method: 'PUT',
|
||||
// body: transformFrontendToApi(data),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 수정에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: _id,
|
||||
serialNumber: _data.serialNumber,
|
||||
name: _data.name,
|
||||
faceValue: _data.faceValue,
|
||||
purchaseDate: _data.purchaseDate,
|
||||
usedDate: _data.usedDate || null,
|
||||
status: _data.status,
|
||||
entertainmentExpense: _data.entertainmentExpense,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
method: 'PUT',
|
||||
body: transformFormToApi(data),
|
||||
transform: (d: LoanApiData) => transformApiToRecord(d),
|
||||
errorMessage: '상품권 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 삭제 (Mock) =====
|
||||
// ===== 상품권 삭제 =====
|
||||
export async function deleteGiftCertificate(
|
||||
_id: string
|
||||
id: string
|
||||
): Promise<ActionResult> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// method: 'DELETE',
|
||||
// errorMessage: '상품권 삭제에 실패했습니다.',
|
||||
// });
|
||||
return { success: true };
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '상품권 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 요약 통계 (Mock) =====
|
||||
export async function getGiftCertificateSummary(_params?: {
|
||||
// ===== 상품권 요약 통계 =====
|
||||
export async function getGiftCertificateSummary(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<ActionResult<{
|
||||
@@ -151,23 +113,31 @@ export async function getGiftCertificateSummary(_params?: {
|
||||
entertainmentCount: number;
|
||||
entertainmentAmount: number;
|
||||
}>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }),
|
||||
// transform: transformSummary,
|
||||
// errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
holdingCount: 0,
|
||||
holdingAmount: 0,
|
||||
usedCount: 0,
|
||||
usedAmount: 0,
|
||||
entertainmentCount: 0,
|
||||
entertainmentAmount: 0,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/loans/summary', {
|
||||
category: 'gift_certificate',
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
}),
|
||||
transform: (data: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
holding_count?: number;
|
||||
holding_amount?: number;
|
||||
used_count?: number;
|
||||
used_amount?: number;
|
||||
entertainment_count?: number;
|
||||
entertainment_amount?: number;
|
||||
}) => ({
|
||||
totalCount: data.total_count ?? 0,
|
||||
totalAmount: data.total_amount ?? 0,
|
||||
holdingCount: data.holding_count ?? 0,
|
||||
holdingAmount: data.holding_amount ?? 0,
|
||||
usedCount: data.used_count ?? 0,
|
||||
usedAmount: data.used_amount ?? 0,
|
||||
entertainmentCount: data.entertainment_count ?? 0,
|
||||
entertainmentAmount: data.entertainment_amount ?? 0,
|
||||
}),
|
||||
errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,8 +44,10 @@ import type {
|
||||
import {
|
||||
getGiftCertificates,
|
||||
getGiftCertificateSummary,
|
||||
deleteGiftCertificate,
|
||||
} from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { applyFilters, enumFilter } from '@/lib/utils/search';
|
||||
import { useDateRange } from '@/hooks';
|
||||
@@ -123,7 +125,7 @@ export function GiftCertificateManagement() {
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
|
||||
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
|
||||
router.push(`/accounting/gift-certificates?mode=view&id=${item.id}`);
|
||||
}, [router]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
@@ -145,6 +147,14 @@ export function GiftCertificateManagement() {
|
||||
data,
|
||||
totalCount: data.length,
|
||||
}),
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteGiftCertificate(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
await loadData();
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
@@ -359,7 +369,7 @@ export function GiftCertificateManagement() {
|
||||
);
|
||||
},
|
||||
}),
|
||||
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
|
||||
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate, loadData]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -104,3 +104,94 @@ export function createEmptyFormData(): GiftCertificateFormData {
|
||||
|
||||
// ===== 액면가 50만원 기준 =====
|
||||
export const FACE_VALUE_THRESHOLD = 500000;
|
||||
|
||||
// ===== Loan API 응답 타입 =====
|
||||
export interface LoanApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
user_id: number | null;
|
||||
loan_date: string;
|
||||
amount: string;
|
||||
purpose: string | null;
|
||||
settlement_date: string | null;
|
||||
settlement_amount: string | null;
|
||||
status: string;
|
||||
category: string | null;
|
||||
metadata: {
|
||||
serial_number?: string;
|
||||
cert_name?: string;
|
||||
vendor_id?: string;
|
||||
vendor_name?: string;
|
||||
purchase_purpose?: string;
|
||||
entertainment_expense?: string;
|
||||
recipient_name?: string;
|
||||
recipient_organization?: string;
|
||||
usage_description?: string;
|
||||
memo?: string;
|
||||
} | null;
|
||||
withdrawal_id: number | null;
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
user?: { id: number; name: string; email: string } | null;
|
||||
creator?: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
// ===== API → 프론트 변환 (목록용) =====
|
||||
export function transformApiToRecord(api: LoanApiData): GiftCertificateRecord {
|
||||
const meta = api.metadata ?? {};
|
||||
return {
|
||||
id: String(api.id),
|
||||
serialNumber: meta.serial_number ?? '',
|
||||
name: meta.cert_name ?? '',
|
||||
faceValue: parseFloat(api.amount) || 0,
|
||||
purchaseDate: api.loan_date ?? '',
|
||||
usedDate: api.settlement_date ?? null,
|
||||
status: (api.status as GiftCertificateStatus) ?? 'holding',
|
||||
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== API → 프론트 변환 (상세/폼용) =====
|
||||
export function transformApiToFormData(api: LoanApiData): GiftCertificateFormData {
|
||||
const meta = api.metadata ?? {};
|
||||
return {
|
||||
serialNumber: meta.serial_number ?? '',
|
||||
name: meta.cert_name ?? '',
|
||||
faceValue: parseFloat(api.amount) || 0,
|
||||
vendorId: meta.vendor_id ?? '',
|
||||
vendorName: meta.vendor_name ?? '',
|
||||
purchaseDate: api.loan_date ?? '',
|
||||
purchasePurpose: (meta.purchase_purpose as PurchasePurpose) ?? 'promotion',
|
||||
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
|
||||
status: (api.status as GiftCertificateStatus) ?? 'holding',
|
||||
usedDate: api.settlement_date ?? '',
|
||||
recipientName: meta.recipient_name ?? '',
|
||||
recipientOrganization: meta.recipient_organization ?? '',
|
||||
usageDescription: meta.usage_description ?? '',
|
||||
memo: meta.memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 프론트 → API 변환 =====
|
||||
export function transformFormToApi(data: GiftCertificateFormData): Record<string, unknown> {
|
||||
return {
|
||||
loan_date: data.purchaseDate,
|
||||
amount: data.faceValue,
|
||||
purpose: data.usageDescription || null,
|
||||
category: 'gift_certificate',
|
||||
status: data.status,
|
||||
settlement_date: data.usedDate || null,
|
||||
metadata: {
|
||||
serial_number: data.serialNumber || null,
|
||||
cert_name: data.name || null,
|
||||
vendor_id: data.vendorId || null,
|
||||
vendor_name: data.vendorName || null,
|
||||
purchase_purpose: data.purchasePurpose || null,
|
||||
entertainment_expense: data.entertainmentExpense || null,
|
||||
recipient_name: data.recipientName || null,
|
||||
recipient_organization: data.recipientOrganization || null,
|
||||
usage_description: data.usageDescription || null,
|
||||
memo: data.memo || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ import { purchaseConfig } from './purchaseConfig';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
|
||||
import { PURCHASE_TYPE_LABELS } from './types';
|
||||
import type { PurchaseRecord, PurchaseItem } from './types';
|
||||
import {
|
||||
getPurchaseById,
|
||||
createPurchase,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
deletePurchase,
|
||||
} from './actions';
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { toast } from 'sonner';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
|
||||
@@ -74,7 +74,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
const [purchaseType, setPurchaseType] = useState<PurchaseType>('unset');
|
||||
// purchaseType 삭제됨 (기획서 P.109)
|
||||
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
|
||||
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
|
||||
|
||||
@@ -126,7 +126,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
setPurchaseDate(data.purchaseDate);
|
||||
setVendorId(data.vendorId);
|
||||
setVendorName(data.vendorName);
|
||||
setPurchaseType(data.purchaseType);
|
||||
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
|
||||
setTaxInvoiceReceived(data.taxInvoiceReceived);
|
||||
setSourceDocument(data.sourceDocument);
|
||||
@@ -250,7 +249,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
supplyAmount: totals.supplyAmount,
|
||||
vat: totals.vat,
|
||||
totalAmount: totals.total,
|
||||
purchaseType,
|
||||
taxInvoiceReceived,
|
||||
};
|
||||
|
||||
@@ -263,6 +261,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
invalidateDashboard('purchase');
|
||||
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -275,7 +274,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]);
|
||||
}, [purchaseDate, vendorId, totals, taxInvoiceReceived, isNewMode, purchaseId]);
|
||||
|
||||
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -285,6 +284,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const result = await deletePurchase(purchaseId);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('purchase');
|
||||
toast.success('매입이 삭제되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -301,179 +301,101 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* ===== 기본 정보 섹션 ===== */}
|
||||
{/* ===== 기본 정보 섹션 (품의서/지출결의서 + 예상비용) ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 품의서/지출결의서인 경우 전용 레이아웃 */}
|
||||
{sourceDocument ? (
|
||||
<>
|
||||
{/* 문서 타입 및 열람 버튼 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={getPresetStyle('orange')}>
|
||||
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">연결된 문서가 있습니다</span>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 품의서/지출결의서 */}
|
||||
<div className="space-y-2">
|
||||
<Label>품의서</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={sourceDocument ? `${sourceDocument.documentNo} ${sourceDocument.title}` : ''}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="연결된 품의서 없음"
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:ml-auto border-orange-300 text-orange-700 hover:bg-orange-100 w-full sm:w-auto"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenDocument}
|
||||
disabled={!sourceDocument}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
문서 열람
|
||||
열람
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 품의서/지출결의서용 필드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 품의서/지출결의서 제목 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} 제목</Label>
|
||||
<Input
|
||||
value={sourceDocument.title}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 예상비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>예상비용</Label>
|
||||
<Input
|
||||
value={`${formatAmount(sourceDocument.expectedCost)}원`}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매입번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입번호</Label>
|
||||
<Input
|
||||
value={purchaseNo}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={vendorId}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 매입 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입 유형</Label>
|
||||
<Select
|
||||
value={purchaseType}
|
||||
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="매입 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 일반 매입 (품의서/지출결의서 없는 경우) */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 매입번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입번호</Label>
|
||||
<Input
|
||||
value={purchaseNo}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매입일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입일</Label>
|
||||
<DatePicker
|
||||
value={purchaseDate}
|
||||
onChange={setPurchaseDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={vendorId}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 매입 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입 유형</Label>
|
||||
<Select
|
||||
value={purchaseType}
|
||||
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="매입 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 예상비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>예상비용</Label>
|
||||
<Input
|
||||
value={sourceDocument ? `${formatAmount(sourceDocument.expectedCost)}원` : ''}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="-"
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 매입 정보 섹션 ===== */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">매입 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* 매입번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입번호</Label>
|
||||
<Input
|
||||
value={purchaseNo}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매입일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입일</Label>
|
||||
<DatePicker
|
||||
value={purchaseDate}
|
||||
onChange={setPurchaseDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={vendorId}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제)
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -57,12 +56,11 @@ import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import type { PurchaseRecord } from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
PURCHASE_TYPE_LABELS,
|
||||
PURCHASE_TYPE_FILTER_OPTIONS,
|
||||
ISSUANCE_FILTER_OPTIONS,
|
||||
TAX_INVOICE_RECEIVED_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
@@ -71,11 +69,9 @@ const tableColumns = [
|
||||
{ key: 'purchaseNo', label: '매입번호', sortable: true },
|
||||
{ key: 'purchaseDate', label: '매입일', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'sourceDocument', label: '연결문서', className: 'text-center', sortable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
|
||||
{ key: 'purchaseType', label: '매입유형', className: 'text-center', sortable: true },
|
||||
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
|
||||
];
|
||||
|
||||
@@ -92,8 +88,7 @@ export function PurchaseManagement() {
|
||||
// 통합 필터 상태 (filterConfig 기반)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
vendor: 'all',
|
||||
purchaseType: 'all',
|
||||
issuance: 'all',
|
||||
taxInvoiceReceived: 'all',
|
||||
sort: 'latest',
|
||||
});
|
||||
|
||||
@@ -142,9 +137,8 @@ export function PurchaseManagement() {
|
||||
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
|
||||
})
|
||||
.reduce((sum, d) => sum + d.totalAmount, 0);
|
||||
const unsetTypeCount = purchaseData.filter(d => d.purchaseType === 'unset').length;
|
||||
const taxInvoicePendingCount = purchaseData.filter(d => !d.taxInvoiceReceived).length;
|
||||
return { totalPurchaseAmount, monthlyAmount, unsetTypeCount, taxInvoicePendingCount };
|
||||
return { totalPurchaseAmount, monthlyAmount, taxInvoicePendingCount };
|
||||
}, [purchaseData]);
|
||||
|
||||
// ===== 거래처 목록 (필터용) =====
|
||||
@@ -163,17 +157,10 @@ export function PurchaseManagement() {
|
||||
allOptionLabel: '거래처 전체',
|
||||
},
|
||||
{
|
||||
key: 'purchaseType',
|
||||
label: '매입유형',
|
||||
key: 'taxInvoiceReceived',
|
||||
label: '세금계산서 수취여부',
|
||||
type: 'single',
|
||||
options: PURCHASE_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'issuance',
|
||||
label: '발행여부',
|
||||
type: 'single',
|
||||
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
options: TAX_INVOICE_RECEIVED_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
@@ -194,8 +181,7 @@ export function PurchaseManagement() {
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setFilterValues({
|
||||
vendor: 'all',
|
||||
purchaseType: 'all',
|
||||
issuance: 'all',
|
||||
taxInvoiceReceived: 'all',
|
||||
sort: 'latest',
|
||||
});
|
||||
}, []);
|
||||
@@ -268,6 +254,7 @@ export function PurchaseManagement() {
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deletePurchase(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('purchase');
|
||||
setPurchaseData(prev => prev.filter(item => item.id !== id));
|
||||
toast.success('매입이 삭제되었습니다.');
|
||||
}
|
||||
@@ -309,18 +296,16 @@ export function PurchaseManagement() {
|
||||
}
|
||||
|
||||
const vendorVal = fv.vendor as string;
|
||||
const purchaseTypeVal = fv.purchaseType as string;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
const taxInvoiceReceivedVal = fv.taxInvoiceReceived as string;
|
||||
// 거래처 필터
|
||||
if (vendorVal !== 'all' && item.vendorName !== vendorVal) {
|
||||
return false;
|
||||
}
|
||||
// 매입유형 필터
|
||||
if (purchaseTypeVal !== 'all' && item.purchaseType !== purchaseTypeVal) {
|
||||
// 세금계산서 수취여부 필터
|
||||
if (taxInvoiceReceivedVal === 'received' && !item.taxInvoiceReceived) {
|
||||
return false;
|
||||
}
|
||||
// 발행여부 필터
|
||||
if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceReceived) {
|
||||
if (taxInvoiceReceivedVal === 'notReceived' && item.taxInvoiceReceived) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -393,9 +378,8 @@ export function PurchaseManagement() {
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
{ label: '총 매입', value: `${formatNumber(stats.totalPurchaseAmount)}원`, icon: Receipt, iconColor: 'text-blue-500' },
|
||||
{ label: '총매입', value: `${formatNumber(stats.totalPurchaseAmount)}원`, icon: Receipt, iconColor: 'text-blue-500' },
|
||||
{ label: '당월 매입', value: `${formatNumber(stats.monthlyAmount)}원`, icon: Receipt, iconColor: 'text-green-500' },
|
||||
{ label: '매입유형 미설정', value: `${stats.unsetTypeCount}건`, icon: Receipt, iconColor: 'text-orange-500' },
|
||||
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' },
|
||||
],
|
||||
|
||||
@@ -406,13 +390,10 @@ export function PurchaseManagement() {
|
||||
<TableCell className="text-center"></TableCell>
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalSupplyAmount)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalVat)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
@@ -428,9 +409,7 @@ export function PurchaseManagement() {
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PurchaseRecord>
|
||||
) => {
|
||||
const isUnsetType = item.purchaseType === 'unset';
|
||||
return (
|
||||
) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
@@ -443,26 +422,9 @@ export function PurchaseManagement() {
|
||||
<TableCell className="text-sm font-medium">{item.purchaseNo}</TableCell>
|
||||
<TableCell>{item.purchaseDate}</TableCell>
|
||||
<TableCell>{item.vendorName}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.sourceDocument ? (
|
||||
<Badge variant="outline" className={`text-xs ${getPresetStyle('info')}`}>
|
||||
{item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.vat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={isUnsetType ? 'border-red-500 text-red-500 bg-red-50' : ''}
|
||||
>
|
||||
{PURCHASE_TYPE_LABELS[item.purchaseType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Switch
|
||||
@@ -474,8 +436,7 @@ export function PurchaseManagement() {
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
@@ -488,14 +449,11 @@ export function PurchaseManagement() {
|
||||
key={item.id}
|
||||
title={item.vendorName}
|
||||
subtitle={item.purchaseNo}
|
||||
badge={PURCHASE_TYPE_LABELS[item.purchaseType]}
|
||||
badgeVariant="outline"
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '매입일', value: item.purchaseDate },
|
||||
{ label: '연결문서', value: item.sourceDocument ? (item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서') : '-' },
|
||||
{ label: '공급가액', value: `${formatNumber(item.supplyAmount)}원` },
|
||||
{ label: '합계금액', value: `${formatNumber(item.totalAmount)}원` },
|
||||
]}
|
||||
|
||||
@@ -81,8 +81,8 @@ export interface PurchaseRecord {
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
||||
|
||||
// 발행여부 필터
|
||||
export type IssuanceFilter = 'all' | 'taxInvoicePending';
|
||||
// 세금계산서 수취여부 필터
|
||||
export type TaxInvoiceReceivedFilter = 'all' | 'received' | 'notReceived';
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
@@ -154,10 +154,11 @@ export const PURCHASE_TYPE_FILTER_OPTIONS: { value: string; label: string }[] =
|
||||
{ value: 'unset', label: '미설정' },
|
||||
];
|
||||
|
||||
// 발행여부 필터 옵션
|
||||
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
|
||||
// 세금계산서 수취여부 필터 옵션
|
||||
export const TAX_INVOICE_RECEIVED_FILTER_OPTIONS: { value: TaxInvoiceReceivedFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'taxInvoicePending', label: '세금계산서 미수취' },
|
||||
{ value: 'received', label: '수취 확인' },
|
||||
{ value: 'notReceived', label: '수취 미확인' },
|
||||
];
|
||||
|
||||
// 계정과목명 셀렉터 옵션 (상단 일괄 변경용)
|
||||
|
||||
@@ -32,9 +32,10 @@ import {
|
||||
CATEGORY_LABELS,
|
||||
SORT_OPTIONS,
|
||||
} from './types';
|
||||
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
|
||||
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { filterByText } from '@/lib/utils/search';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
@@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 엑셀 다운로드 핸들러 =====
|
||||
// ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportReceivablesExcel({
|
||||
year: selectedYear,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || '채권현황.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
// 데이터가 이미 로드되어 있으므로 sortedData 사용
|
||||
if (sortedData.length === 0) {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
// 동적 월 컬럼 포함 엑셀 컬럼 생성
|
||||
const columns: ExcelColumn<Record<string, unknown>>[] = [
|
||||
{ header: '거래처', key: 'vendorName', width: 20 },
|
||||
{ header: '연체', key: 'isOverdue', width: 8 },
|
||||
...monthLabels.map((label, idx) => ({
|
||||
header: label, key: `month_${idx}`, width: 12,
|
||||
})),
|
||||
{ header: '합계', key: 'total', width: 14 },
|
||||
{ header: '메모', key: 'memo', width: 20 },
|
||||
];
|
||||
// 미수금 카테고리 기준으로 플랫 데이터 생성
|
||||
const exportData = sortedData.map(vendor => {
|
||||
const receivable = vendor.categories.find(c => c.category === 'receivable');
|
||||
const row: Record<string, unknown> = {
|
||||
vendorName: vendor.vendorName,
|
||||
isOverdue: vendor.isOverdue ? '연체' : '',
|
||||
};
|
||||
monthLabels.forEach((_, idx) => {
|
||||
row[`month_${idx}`] = receivable?.amounts.values[idx] || 0;
|
||||
});
|
||||
row.total = receivable?.amounts.total || 0;
|
||||
row.memo = vendor.memo || '';
|
||||
return row;
|
||||
});
|
||||
await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' });
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [selectedYear, searchQuery]);
|
||||
}, [sortedData, monthLabels]);
|
||||
|
||||
// ===== 변경된 연체 항목 확인 =====
|
||||
const changedOverdueItems = useMemo(() => {
|
||||
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
|
||||
import { salesConfig } from './salesConfig';
|
||||
import type { SalesRecord, SalesItem, SalesType } from './types';
|
||||
import { SALES_TYPE_OPTIONS } from './types';
|
||||
import type { SalesRecord, SalesItem } from './types';
|
||||
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { toast } from 'sonner';
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
|
||||
@@ -78,7 +78,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
const [salesType, setSalesType] = useState<SalesType>('product');
|
||||
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
|
||||
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
|
||||
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
|
||||
@@ -126,7 +125,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
setSalesDate(data.salesDate);
|
||||
setVendorId(data.vendorId);
|
||||
setVendorName(data.vendorName);
|
||||
setSalesType(data.salesType);
|
||||
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
|
||||
setTaxInvoiceIssued(data.taxInvoiceIssued);
|
||||
setTransactionStatementIssued(data.transactionStatementIssued);
|
||||
@@ -158,7 +156,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const saleData: Partial<SalesRecord> = {
|
||||
salesDate,
|
||||
vendorId,
|
||||
salesType,
|
||||
items,
|
||||
totalSupplyAmount: totals.supplyAmount,
|
||||
totalVat: totals.vat,
|
||||
@@ -177,6 +174,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
invalidateDashboard('sales');
|
||||
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -189,7 +187,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [salesDate, vendorId, salesType, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
||||
}, [salesDate, vendorId, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
|
||||
|
||||
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -199,6 +197,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const result = await deleteSale(salesId);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('sales');
|
||||
toast.success('매출이 삭제되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -268,23 +267,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 매출 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salesType">매출 유형</Label>
|
||||
<Select value={salesType} onValueChange={(v) => setSalesType(v as SalesType)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -318,28 +300,42 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<CardTitle className="text-lg">세금계산서</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="taxInvoice">세금계산서 발행</Label>
|
||||
<Switch
|
||||
id="taxInvoice"
|
||||
checked={taxInvoiceIssued}
|
||||
onCheckedChange={setTaxInvoiceIssued}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="taxInvoice">세금계산서 발행</Label>
|
||||
<Switch
|
||||
id="taxInvoice"
|
||||
checked={taxInvoiceIssued}
|
||||
onCheckedChange={setTaxInvoiceIssued}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{taxInvoiceIssued ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
발행완료
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
미발행
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{taxInvoiceIssued ? (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
발행완료
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
미발행
|
||||
</span>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={() => {
|
||||
toast.info('세금계산서 발행 기능 준비 중입니다.');
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
세금계산서 발행하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -55,11 +54,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import type { SalesRecord } from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
SALES_STATUS_LABELS,
|
||||
SALES_STATUS_COLORS,
|
||||
SALES_TYPE_LABELS,
|
||||
SALES_TYPE_FILTER_OPTIONS,
|
||||
ISSUANCE_FILTER_OPTIONS,
|
||||
TAX_INVOICE_FILTER_OPTIONS,
|
||||
TRANSACTION_STATEMENT_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getSales, deleteSale, toggleSaleIssuance } from './actions';
|
||||
@@ -83,7 +79,6 @@ const tableColumns = [
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
|
||||
{ key: 'salesType', label: '매출유형', className: 'text-center', sortable: true },
|
||||
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
|
||||
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
|
||||
];
|
||||
@@ -113,8 +108,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
// 필터 초기값 (filterConfig 기반 - ULP가 내부 state로 관리)
|
||||
const initialFilterValues: Record<string, string | string[]> = {
|
||||
vendor: 'all',
|
||||
salesType: 'all',
|
||||
issuance: 'all',
|
||||
taxInvoice: 'all',
|
||||
transactionStatement: 'all',
|
||||
sort: 'latest',
|
||||
};
|
||||
|
||||
@@ -148,17 +143,17 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
allOptionLabel: '거래처 전체',
|
||||
},
|
||||
{
|
||||
key: 'salesType',
|
||||
label: '매출유형',
|
||||
key: 'taxInvoice',
|
||||
label: '세금계산서 발행여부',
|
||||
type: 'single',
|
||||
options: SALES_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
options: TAX_INVOICE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'issuance',
|
||||
label: '발행여부',
|
||||
key: 'transactionStatement',
|
||||
label: '거래명세서 발행여부',
|
||||
type: 'single',
|
||||
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
options: TRANSACTION_STATEMENT_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
@@ -322,18 +317,24 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
// 검색은 searchFilter에서 처리하므로 여기서는 필터만 처리
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
const taxInvoiceVal = fv.taxInvoice as string;
|
||||
const transactionStatementVal = fv.transactionStatement as string;
|
||||
|
||||
let result = applyFilters(items, [
|
||||
enumFilter('vendorName', fv.vendor as string),
|
||||
enumFilter('salesType', fv.salesType as string),
|
||||
]);
|
||||
|
||||
// 발행여부 필터 (특수 로직 - enumFilter로 대체 불가)
|
||||
if (issuanceVal === 'taxInvoicePending') {
|
||||
// 세금계산서 발행여부 필터
|
||||
if (taxInvoiceVal === 'issued') {
|
||||
result = result.filter(item => item.taxInvoiceIssued);
|
||||
} else if (taxInvoiceVal === 'notIssued') {
|
||||
result = result.filter(item => !item.taxInvoiceIssued);
|
||||
}
|
||||
if (issuanceVal === 'transactionStatementPending') {
|
||||
|
||||
// 거래명세서 발행여부 필터
|
||||
if (transactionStatementVal === 'issued') {
|
||||
result = result.filter(item => item.transactionStatementIssued);
|
||||
} else if (transactionStatementVal === 'notIssued') {
|
||||
result = result.filter(item => !item.transactionStatementIssued);
|
||||
}
|
||||
|
||||
@@ -411,7 +412,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
@@ -443,9 +443,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
<TableCell className="text-right">{formatNumber(item.totalSupplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.totalVat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{SALES_TYPE_LABELS[item.salesType]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Switch
|
||||
@@ -480,8 +477,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
key={item.id}
|
||||
title={item.vendorName}
|
||||
subtitle={item.salesNo}
|
||||
badge={SALES_TYPE_LABELS[item.salesType]}
|
||||
badgeVariant="outline"
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
|
||||
@@ -133,13 +133,22 @@ export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS = [
|
||||
{ value: 'other', label: '기타매출' },
|
||||
];
|
||||
|
||||
// ===== 발행여부 필터 =====
|
||||
export type IssuanceFilter = 'all' | 'taxInvoicePending' | 'transactionStatementPending';
|
||||
// ===== 세금계산서 발행여부 필터 =====
|
||||
export type TaxInvoiceFilter = 'all' | 'issued' | 'notIssued';
|
||||
|
||||
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
|
||||
export const TAX_INVOICE_FILTER_OPTIONS: { value: TaxInvoiceFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'taxInvoicePending', label: '세금계산서 미발행' },
|
||||
{ value: 'transactionStatementPending', label: '거래명세서 미발행' },
|
||||
{ value: 'issued', label: '발행완료' },
|
||||
{ value: 'notIssued', label: '미발행' },
|
||||
];
|
||||
|
||||
// ===== 거래명세서 발행여부 필터 =====
|
||||
export type TransactionStatementFilter = 'all' | 'issued' | 'notIssued';
|
||||
|
||||
export const TRANSACTION_STATEMENT_FILTER_OPTIONS: { value: TransactionStatementFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'issued', label: '발행완료' },
|
||||
{ value: 'notIssued', label: '미발행' },
|
||||
];
|
||||
|
||||
// ===== 매출유형 필터 옵션 (스크린샷 기준) =====
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function searchVendorsForTaxInvoice(
|
||||
url: buildApiUrl('/api/v1/clients', {
|
||||
q: query || undefined,
|
||||
only_active: true,
|
||||
size: 100,
|
||||
size: 1000,
|
||||
}),
|
||||
transform: (data: { data: ClientApiData[] }) =>
|
||||
data.data.map((item) => ({
|
||||
|
||||
@@ -53,11 +53,11 @@ import {
|
||||
updateJournalEntry,
|
||||
deleteJournalEntry,
|
||||
} from './actions';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
|
||||
import {
|
||||
TAB_OPTIONS,
|
||||
JOURNAL_SIDE_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
|
||||
interface JournalEntryModalProps {
|
||||
@@ -288,25 +288,14 @@ export function JournalEntryModal({
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubject}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubject', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
|
||||
(opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -199,12 +200,14 @@ export function ManualEntryModal({
|
||||
onChange={(value) => handleChange('vendorName', value)}
|
||||
placeholder="공급자명"
|
||||
/>
|
||||
<FormField
|
||||
label="사업자번호"
|
||||
value={formData.vendorBusinessNumber}
|
||||
onChange={(value) => handleChange('vendorBusinessNumber', value)}
|
||||
placeholder="사업자번호"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<BusinessNumberInput
|
||||
value={formData.vendorBusinessNumber}
|
||||
onChange={(value) => handleChange('vendorBusinessNumber', value)}
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
TaxInvoiceMgmtApiData,
|
||||
TaxInvoiceSummary,
|
||||
TaxInvoiceSummaryApiData,
|
||||
CardHistoryRecord,
|
||||
CardHistoryApiData,
|
||||
CardHistoryRecord,
|
||||
ManualEntryFormData,
|
||||
JournalEntryRow,
|
||||
} from './types';
|
||||
@@ -20,17 +20,6 @@ import {
|
||||
transformSummaryApi,
|
||||
} from './types';
|
||||
|
||||
// ===== 세금계산서 목록 Mock =====
|
||||
// TODO: 실제 API 연동 시 Mock 제거
|
||||
const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [
|
||||
{ id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' },
|
||||
{ id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' },
|
||||
{ id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' },
|
||||
{ id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' },
|
||||
{ id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' },
|
||||
{ id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' },
|
||||
];
|
||||
|
||||
// ===== 세금계산서 목록 조회 =====
|
||||
export async function getTaxInvoices(params: {
|
||||
division?: string;
|
||||
@@ -41,45 +30,39 @@ export async function getTaxInvoices(params: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) {
|
||||
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||
// return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
|
||||
// url: buildApiUrl('/api/v1/tax-invoices', { ... }),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
|
||||
// });
|
||||
const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales'));
|
||||
return {
|
||||
success: true as const,
|
||||
data: filtered,
|
||||
error: undefined as string | undefined,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length },
|
||||
};
|
||||
// frontend 'purchase' → backend 'purchases'
|
||||
const direction = params.division === 'purchase' ? 'purchases' : params.division;
|
||||
|
||||
return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
|
||||
url: buildApiUrl('/api/v1/tax-invoices', {
|
||||
direction,
|
||||
issue_date_from: params.startDate,
|
||||
issue_date_to: params.endDate,
|
||||
corp_name: params.vendorSearch || undefined,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
}),
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '세금계산서 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 세금계산서 요약 조회 =====
|
||||
export async function getTaxInvoiceSummary(_params: {
|
||||
export async function getTaxInvoiceSummary(params: {
|
||||
dateType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
vendorSearch?: string;
|
||||
}): Promise<ActionResult<TaxInvoiceSummary>> {
|
||||
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||
// return executeServerAction({ ... });
|
||||
const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales');
|
||||
const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0),
|
||||
salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0),
|
||||
salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0),
|
||||
salesCount: sales.length,
|
||||
purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0),
|
||||
purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0),
|
||||
purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0),
|
||||
purchaseCount: purchase.length,
|
||||
},
|
||||
};
|
||||
return executeServerAction<TaxInvoiceSummaryApiData, TaxInvoiceSummary>({
|
||||
url: buildApiUrl('/api/v1/tax-invoices/summary', {
|
||||
issue_date_from: params.startDate,
|
||||
issue_date_to: params.endDate,
|
||||
corp_name: params.vendorSearch || undefined,
|
||||
}),
|
||||
transform: transformSummaryApi,
|
||||
errorMessage: '세금계산서 요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 세금계산서 수기 등록 =====
|
||||
@@ -96,35 +79,24 @@ export async function createTaxInvoice(
|
||||
}
|
||||
|
||||
// ===== 카드 내역 조회 =====
|
||||
// TODO: 실제 API 연동 시 Mock 제거
|
||||
const MOCK_CARD_HISTORY: CardHistoryRecord[] = [
|
||||
{ id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' },
|
||||
{ id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' },
|
||||
{ id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' },
|
||||
{ id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' },
|
||||
{ id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' },
|
||||
];
|
||||
|
||||
export async function getCardHistory(_params: {
|
||||
export async function getCardHistory(params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}): Promise<ActionResult<CardHistoryRecord[]>> {
|
||||
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||
// return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
|
||||
// url: buildApiUrl('/api/v1/card-transactions/history', {
|
||||
// start_date: _params.startDate,
|
||||
// end_date: _params.endDate,
|
||||
// search: _params.search || undefined,
|
||||
// page: _params.page,
|
||||
// per_page: _params.perPage,
|
||||
// }),
|
||||
// transform: transformCardHistoryApi,
|
||||
// errorMessage: '카드 내역 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: true, data: MOCK_CARD_HISTORY };
|
||||
}) {
|
||||
return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
|
||||
url: buildApiUrl('/api/v1/card-transactions', {
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
search: params.search || undefined,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
}),
|
||||
transform: transformCardHistoryApi,
|
||||
errorMessage: '카드 내역 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 분개 내역 조회 =====
|
||||
|
||||
@@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import {
|
||||
getTaxInvoices,
|
||||
getTaxInvoiceSummary,
|
||||
downloadTaxInvoiceExcel,
|
||||
} from './actions';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
|
||||
const ManualEntryModal = dynamic(
|
||||
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
|
||||
@@ -58,6 +58,10 @@ import type {
|
||||
TaxInvoiceMgmtRecord,
|
||||
InvoiceTab,
|
||||
TaxInvoiceSummary,
|
||||
TaxType,
|
||||
ReceiptType,
|
||||
InvoiceStatus,
|
||||
InvoiceSource,
|
||||
} from './types';
|
||||
import {
|
||||
TAB_OPTIONS,
|
||||
@@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [
|
||||
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
|
||||
];
|
||||
|
||||
// ===== 엑셀 다운로드 컬럼 =====
|
||||
const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[] = [
|
||||
{ header: '작성일자', key: 'writeDate', width: 12 },
|
||||
{ header: '발급일자', key: 'issueDate', width: 12 },
|
||||
{ header: '거래처', key: 'vendorName', width: 20 },
|
||||
{ header: '사업자번호', key: 'vendorBusinessNumber', width: 15 },
|
||||
{ header: '과세형태', key: 'taxType', width: 10,
|
||||
transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') },
|
||||
{ header: '품목', key: 'itemName', width: 15 },
|
||||
{ header: '공급가액', key: 'supplyAmount', width: 14 },
|
||||
{ header: '세액', key: 'taxAmount', width: 14 },
|
||||
{ header: '합계', key: 'totalAmount', width: 14 },
|
||||
{ header: '영수청구', key: 'receiptType', width: 10,
|
||||
transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') },
|
||||
{ header: '상태', key: 'status', width: 10,
|
||||
transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') },
|
||||
{ header: '발급형태', key: 'source', width: 10,
|
||||
transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') },
|
||||
];
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = [
|
||||
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
|
||||
@@ -224,19 +248,46 @@ export function TaxInvoiceManagement() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
// ===== 엑셀 다운로드 (프론트 xlsx 생성) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await downloadTaxInvoiceExcel({
|
||||
division: activeTab,
|
||||
dateType,
|
||||
startDate,
|
||||
endDate,
|
||||
vendorSearch,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
window.open(result.data.url, '_blank');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: TaxInvoiceMgmtRecord[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getTaxInvoices({
|
||||
division: activeTab,
|
||||
dateType,
|
||||
startDate,
|
||||
endDate,
|
||||
vendorSearch,
|
||||
page,
|
||||
perPage: 100,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel({
|
||||
data: allData as (TaxInvoiceMgmtRecord & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`,
|
||||
sheetName: activeTab === 'sales' ? '매출' : '매입',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [activeTab, dateType, startDate, endDate, vendorSearch]);
|
||||
|
||||
|
||||
@@ -45,12 +45,14 @@ export const RECEIPT_TYPE_LABELS: Record<ReceiptType, string> = {
|
||||
};
|
||||
|
||||
// ===== 세금계산서 상태 =====
|
||||
export type InvoiceStatus = 'pending' | 'journalized' | 'error';
|
||||
export type InvoiceStatus = 'draft' | 'issued' | 'sent' | 'cancelled' | 'failed';
|
||||
|
||||
export const INVOICE_STATUS_MAP: Record<InvoiceStatus, { label: string; color: string }> = {
|
||||
pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' },
|
||||
journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' },
|
||||
error: { label: '오류', color: 'bg-red-100 text-red-700' },
|
||||
draft: { label: '임시저장', color: 'bg-gray-100 text-gray-700' },
|
||||
issued: { label: '발급완료', color: 'bg-blue-100 text-blue-700' },
|
||||
sent: { label: '전송완료', color: 'bg-green-100 text-green-700' },
|
||||
cancelled: { label: '취소', color: 'bg-red-100 text-red-700' },
|
||||
failed: { label: '실패', color: 'bg-orange-100 text-orange-700' },
|
||||
};
|
||||
|
||||
// ===== 소스 구분 (수기/홈택스) =====
|
||||
@@ -87,24 +89,25 @@ export interface TaxInvoiceMgmtRecord {
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 (snake_case) =====
|
||||
// ===== API 응답 타입 (백엔드 TaxInvoice 모델 기준) =====
|
||||
export interface TaxInvoiceMgmtApiData {
|
||||
id: number;
|
||||
division: string;
|
||||
write_date: string;
|
||||
direction: string;
|
||||
supplier_corp_num: string | null;
|
||||
supplier_corp_name: string | null;
|
||||
buyer_corp_num: string | null;
|
||||
buyer_corp_name: string | null;
|
||||
issue_date: string | null;
|
||||
vendor_name: string;
|
||||
vendor_business_number: string;
|
||||
tax_type: string;
|
||||
item_name: string;
|
||||
supply_amount: string | number;
|
||||
tax_amount: string | number;
|
||||
total_amount: string | number;
|
||||
receipt_type: string;
|
||||
document_number: string;
|
||||
status: string;
|
||||
source: string;
|
||||
memo: string | null;
|
||||
invoice_type: string | null;
|
||||
issue_type: string | null;
|
||||
nts_confirm_num: string | null;
|
||||
description: string | null;
|
||||
barobill_invoice_id: string | null;
|
||||
items: Array<{ name?: string; [key: string]: unknown }> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -121,15 +124,20 @@ export interface TaxInvoiceSummary {
|
||||
purchaseCount: number;
|
||||
}
|
||||
|
||||
// 백엔드 summary API는 by_direction 중첩 구조로 응답
|
||||
interface DirectionSummary {
|
||||
count: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
export interface TaxInvoiceSummaryApiData {
|
||||
sales_supply_amount: number;
|
||||
sales_tax_amount: number;
|
||||
sales_total_amount: number;
|
||||
sales_count: number;
|
||||
purchase_supply_amount: number;
|
||||
purchase_tax_amount: number;
|
||||
purchase_total_amount: number;
|
||||
purchase_count: number;
|
||||
by_direction: {
|
||||
sales: DirectionSummary;
|
||||
purchases: DirectionSummary;
|
||||
};
|
||||
by_status: Record<string, number>;
|
||||
}
|
||||
|
||||
// ===== 분개 항목 =====
|
||||
@@ -165,11 +173,12 @@ export interface CardHistoryRecord {
|
||||
|
||||
export interface CardHistoryApiData {
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
used_at: string;
|
||||
merchant_name: string;
|
||||
amount: string | number;
|
||||
approval_number: string;
|
||||
business_number: string;
|
||||
approval_number?: string;
|
||||
business_number?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
// ===== 수기 입력 폼 데이터 =====
|
||||
@@ -202,40 +211,66 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
];
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed'];
|
||||
|
||||
const INVOICE_TYPE_TO_TAX_TYPE: Record<string, TaxType> = {
|
||||
tax_invoice: 'taxable',
|
||||
modified: 'taxable',
|
||||
invoice: 'tax_free',
|
||||
};
|
||||
|
||||
const ISSUE_TYPE_TO_RECEIPT_TYPE: Record<string, ReceiptType> = {
|
||||
receipt: 'receipt',
|
||||
claim: 'claim',
|
||||
};
|
||||
|
||||
export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord {
|
||||
const isSales = apiData.direction === 'sales';
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
division: apiData.division as InvoiceTab,
|
||||
writeDate: apiData.write_date,
|
||||
division: isSales ? 'sales' : 'purchase',
|
||||
writeDate: apiData.issue_date || apiData.created_at?.split('T')[0] || '',
|
||||
issueDate: apiData.issue_date,
|
||||
vendorName: apiData.vendor_name,
|
||||
vendorBusinessNumber: apiData.vendor_business_number,
|
||||
taxType: apiData.tax_type as TaxType,
|
||||
itemName: apiData.item_name,
|
||||
supplyAmount: Number(apiData.supply_amount),
|
||||
taxAmount: Number(apiData.tax_amount),
|
||||
totalAmount: Number(apiData.total_amount),
|
||||
receiptType: apiData.receipt_type as ReceiptType,
|
||||
documentNumber: apiData.document_number,
|
||||
status: apiData.status as InvoiceStatus,
|
||||
source: apiData.source as InvoiceSource,
|
||||
memo: apiData.memo || '',
|
||||
vendorName: isSales
|
||||
? (apiData.buyer_corp_name || '')
|
||||
: (apiData.supplier_corp_name || ''),
|
||||
vendorBusinessNumber: isSales
|
||||
? (apiData.buyer_corp_num || '')
|
||||
: (apiData.supplier_corp_num || ''),
|
||||
taxType: INVOICE_TYPE_TO_TAX_TYPE[apiData.invoice_type || ''] || 'taxable',
|
||||
itemName: apiData.items?.[0]?.name || apiData.description || '',
|
||||
supplyAmount: Number(apiData.supply_amount) || 0,
|
||||
taxAmount: Number(apiData.tax_amount) || 0,
|
||||
totalAmount: Number(apiData.total_amount) || 0,
|
||||
receiptType: ISSUE_TYPE_TO_RECEIPT_TYPE[apiData.issue_type || ''] || 'receipt',
|
||||
documentNumber: apiData.nts_confirm_num || '',
|
||||
status: VALID_STATUSES.includes(apiData.status as InvoiceStatus)
|
||||
? (apiData.status as InvoiceStatus)
|
||||
: 'draft',
|
||||
source: apiData.barobill_invoice_id ? 'hometax' : 'manual',
|
||||
memo: apiData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 =====
|
||||
export function transformFrontendToApi(data: ManualEntryFormData): Record<string, unknown> {
|
||||
const isSales = data.division === 'sales';
|
||||
return {
|
||||
division: data.division,
|
||||
write_date: data.writeDate,
|
||||
vendor_name: data.vendorName,
|
||||
vendor_business_number: data.vendorBusinessNumber,
|
||||
direction: isSales ? 'sales' : 'purchases',
|
||||
issue_type: 'normal',
|
||||
issue_date: data.writeDate,
|
||||
// 매출: 거래처=공급받는자(buyer), 매입: 거래처=공급자(supplier)
|
||||
// DB 컬럼이 NOT NULL이므로 빈 문자열로 전송
|
||||
supplier_corp_name: isSales ? '' : data.vendorName,
|
||||
supplier_corp_num: isSales ? '' : data.vendorBusinessNumber,
|
||||
buyer_corp_name: isSales ? data.vendorName : '',
|
||||
buyer_corp_num: isSales ? data.vendorBusinessNumber : '',
|
||||
supply_amount: data.supplyAmount,
|
||||
tax_amount: data.taxAmount,
|
||||
total_amount: data.totalAmount,
|
||||
item_name: data.itemName,
|
||||
tax_type: data.taxType,
|
||||
memo: data.memo || null,
|
||||
invoice_type: data.taxType === 'tax_free' ? 'invoice' : 'tax_invoice',
|
||||
description: data.memo || null,
|
||||
items: data.itemName ? [{ name: data.itemName, amount: data.supplyAmount }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,24 +278,28 @@ export function transformFrontendToApi(data: ManualEntryFormData): Record<string
|
||||
export function transformCardHistoryApi(apiData: CardHistoryApiData): CardHistoryRecord {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
transactionDate: apiData.transaction_date,
|
||||
transactionDate: apiData.used_at,
|
||||
merchantName: apiData.merchant_name,
|
||||
amount: Number(apiData.amount),
|
||||
approvalNumber: apiData.approval_number,
|
||||
businessNumber: apiData.business_number,
|
||||
approvalNumber: apiData.approval_number || '',
|
||||
businessNumber: apiData.business_number || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 요약 API → Frontend 변환 =====
|
||||
const EMPTY_DIRECTION: DirectionSummary = { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 };
|
||||
|
||||
export function transformSummaryApi(apiData: TaxInvoiceSummaryApiData): TaxInvoiceSummary {
|
||||
const sales = apiData.by_direction?.sales || EMPTY_DIRECTION;
|
||||
const purchases = apiData.by_direction?.purchases || EMPTY_DIRECTION;
|
||||
return {
|
||||
salesSupplyAmount: apiData.sales_supply_amount,
|
||||
salesTaxAmount: apiData.sales_tax_amount,
|
||||
salesTotalAmount: apiData.sales_total_amount,
|
||||
salesCount: apiData.sales_count,
|
||||
purchaseSupplyAmount: apiData.purchase_supply_amount,
|
||||
purchaseTaxAmount: apiData.purchase_tax_amount,
|
||||
purchaseTotalAmount: apiData.purchase_total_amount,
|
||||
purchaseCount: apiData.purchase_count,
|
||||
salesSupplyAmount: sales.supply_amount,
|
||||
salesTaxAmount: sales.tax_amount,
|
||||
salesTotalAmount: sales.total_amount,
|
||||
salesCount: sales.count,
|
||||
purchaseSupplyAmount: purchases.supply_amount,
|
||||
purchaseTaxAmount: purchases.tax_amount,
|
||||
purchaseTotalAmount: purchases.total_amount,
|
||||
purchaseCount: purchases.count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { VendorLedgerItem, VendorLedgerSummary } from './types';
|
||||
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
|
||||
import { getVendorLedgerList, getVendorLedgerSummary } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
@@ -43,6 +44,16 @@ const tableColumns = [
|
||||
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
];
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
const excelColumns: ExcelColumn<VendorLedgerItem & Record<string, unknown>>[] = [
|
||||
{ header: '거래처명', key: 'vendorName', width: 20 },
|
||||
{ header: '이월잔액', key: 'carryoverBalance', width: 14 },
|
||||
{ header: '매출', key: 'sales', width: 14 },
|
||||
{ header: '수금', key: 'collection', width: 14 },
|
||||
{ header: '잔액', key: 'balance', width: 14 },
|
||||
{ header: '결제일', key: 'paymentDate', width: 12 },
|
||||
];
|
||||
|
||||
// ===== Props =====
|
||||
interface VendorLedgerProps {
|
||||
initialData?: VendorLedgerItem[];
|
||||
@@ -144,24 +155,42 @@ export function VendorLedger({
|
||||
);
|
||||
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportVendorLedgerExcel({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: VendorLedgerItem[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
if (result.success && result.data) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || '거래처원장.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('엑셀 파일이 다운로드되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
do {
|
||||
const result = await getVendorLedgerList({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel<VendorLedgerItem & Record<string, unknown>>({
|
||||
data: allData as (VendorLedgerItem & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: '거래처원장',
|
||||
sheetName: '거래처원장',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, searchQuery]);
|
||||
|
||||
|
||||
@@ -10,12 +10,6 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
|
||||
import { vendorConfig } from './vendorConfig';
|
||||
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
businessNumber: '사업자등록번호',
|
||||
vendorName: '거래처명',
|
||||
category: '거래처 유형',
|
||||
};
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -29,7 +23,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
// 새 입력 컴포넌트
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
@@ -186,13 +179,25 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [formData.businessNumber, formData.vendorName, formData.category]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: string, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
// 에러 클리어
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 파일 검증 및 추가
|
||||
const validateAndAddFiles = useCallback((files: FileList | File[]) => {
|
||||
@@ -265,7 +270,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 저장 핸들러 (IntegratedDetailTemplate용)
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return { success: false, error: '입력 내용을 확인해주세요.' };
|
||||
}
|
||||
|
||||
@@ -326,8 +330,9 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={isViewMode || disabled}
|
||||
className="bg-white"
|
||||
className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -350,7 +355,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
onValueChange={(val) => handleChange(field, val)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectTrigger className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -361,6 +366,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -368,35 +374,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
// 폼 콘텐츠 렌더링 (View/Edit 공통)
|
||||
const renderFormContent = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -496,6 +473,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
showValidation={!isViewMode}
|
||||
error={!!validationErrors.businessNumber}
|
||||
/>
|
||||
{validationErrors.businessNumber && <p className="text-sm text-red-500">{validationErrors.businessNumber}</p>}
|
||||
</div>
|
||||
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
|
||||
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Plus, Trash2, Upload } from 'lucide-react';
|
||||
@@ -194,6 +195,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
return { success: false, error: result.message || '저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
invalidateDashboard('client');
|
||||
router.refresh();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -214,6 +216,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
return { success: false, error: result.message || '삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
invalidateDashboard('client');
|
||||
router.refresh();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
||||
enumFilter('creditRating', creditRatingFilter),
|
||||
enumFilter('transactionGrade', transactionGradeFilter),
|
||||
enumFilter('badDebtStatus', badDebtFilter),
|
||||
(items: Vendor[]) => items.filter((item) => {
|
||||
if (!item.createdAt) return true;
|
||||
const created = item.createdAt.slice(0, 10);
|
||||
return created >= startDate && created <= endDate;
|
||||
}),
|
||||
]);
|
||||
|
||||
// 정렬
|
||||
@@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
|
||||
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
@@ -131,6 +131,8 @@ export async function getClients(params?: {
|
||||
size?: number;
|
||||
q?: string;
|
||||
only_active?: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', {
|
||||
@@ -138,6 +140,8 @@ export async function getClients(params?: {
|
||||
size: params?.size,
|
||||
q: params?.q,
|
||||
only_active: params?.only_active,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
transform: (data: PaginatedResponse<ClientApiData>) => ({
|
||||
items: data.data.map(transformApiToFrontend),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user