Compare commits
42 Commits
23fa9c0ea2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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 (대시보드 연동)
|
||||
```
|
||||
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별 기존 데이터 확인 후 없는 것만 추가 |
|
||||
@@ -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 | 양쪽 추가 개발 | 무결성, 집계, 조회 |
|
||||
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자 확장
|
||||
@@ -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 대시보드 백엔드 연동 검수 완료. 데이터 인프라 확정.**
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
// 헤더 템플릿 (문서번호, 생성일)
|
||||
|
||||
@@ -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';
|
||||
@@ -24,6 +24,7 @@ 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 +33,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,
|
||||
@@ -96,6 +95,7 @@ export function BillManagementClient({
|
||||
onDelete: async (id) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
|
||||
await loadData(currentPage);
|
||||
setSelectedItems(prev => {
|
||||
@@ -148,6 +148,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 +306,7 @@ export function BillManagementClient({
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건이 저장되었습니다.`);
|
||||
loadData(currentPage);
|
||||
setSelectedItems(new Set());
|
||||
@@ -348,32 +359,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 +379,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 +403,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 +416,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>
|
||||
|
||||
@@ -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,
|
||||
@@ -599,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()}>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { printElement } from '@/lib/print-utils';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
@@ -204,9 +205,19 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, []);
|
||||
|
||||
// ===== 인쇄 =====
|
||||
const printAreaRef = useRef<HTMLDivElement>(null);
|
||||
const handlePrint = useCallback(() => {
|
||||
window.print();
|
||||
}, []);
|
||||
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)}`, []);
|
||||
@@ -299,6 +310,8 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 인쇄 영역 */}
|
||||
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
|
||||
{/* 일자별 입출금 합계 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
@@ -660,6 +673,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
</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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,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';
|
||||
|
||||
@@ -260,6 +261,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
invalidateDashboard('purchase');
|
||||
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -282,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 {
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
@@ -253,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('매입이 삭제되었습니다.');
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTa
|
||||
import { salesConfig } from './salesConfig';
|
||||
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';
|
||||
|
||||
@@ -173,6 +174,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
invalidateDashboard('sales');
|
||||
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -195,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,62 @@ 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_date: data.writeDate,
|
||||
...(isSales
|
||||
? { buyer_corp_name: data.vendorName, buyer_corp_num: data.vendorBusinessNumber }
|
||||
: { supplier_corp_name: data.vendorName, supplier_corp_num: 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 +274,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getBankAccounts,
|
||||
} from './actions';
|
||||
import { useDevFill, generateWithdrawalData } from '@/components/dev';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== Props =====
|
||||
interface WithdrawalDetailClientV2Props {
|
||||
@@ -82,6 +83,7 @@ export default function WithdrawalDetailClientV2({
|
||||
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('withdrawal');
|
||||
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
|
||||
router.push('/ko/accounting/withdrawals');
|
||||
return { success: true };
|
||||
@@ -99,6 +101,7 @@ export default function WithdrawalDetailClientV2({
|
||||
|
||||
const result = await deleteWithdrawal(withdrawalId);
|
||||
if (result.success) {
|
||||
invalidateDashboard('withdrawal');
|
||||
toast.success('출금 내역이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/withdrawals');
|
||||
return { success: true };
|
||||
|
||||
@@ -72,9 +72,9 @@ import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actio
|
||||
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 {
|
||||
createDeleteItemHandler,
|
||||
extractUniqueOptions,
|
||||
createDateAmountSortFn,
|
||||
computeMonthlyTotal,
|
||||
@@ -237,7 +237,15 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
totalCount: initialData.length,
|
||||
};
|
||||
},
|
||||
deleteItem: createDeleteItemHandler(deleteWithdrawal, setWithdrawalData, '출금 내역이 삭제되었습니다.'),
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteWithdrawal(id);
|
||||
if (result.success) {
|
||||
setWithdrawalData(prev => prev.filter(item => item.id !== id));
|
||||
invalidateDashboard('withdrawal');
|
||||
toast.success('출금 내역이 삭제되었습니다.');
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
|
||||
215
src/components/accounting/common/AccountSubjectSelect.tsx
Normal file
215
src/components/accounting/common/AccountSubjectSelect.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계정과목 Select 공용 컴포넌트
|
||||
*
|
||||
* DB 마스터에서 활성 계정과목(소분류, depth=3)을 로드하여 검색 가능한 Select로 표시.
|
||||
* "[코드] 계정과목명" 형태로 표시. 코드/이름으로 검색 가능.
|
||||
* Popover + Command 패턴 (SearchableSelect 기반).
|
||||
* props로 category 제한 가능.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { getAccountSubjects } from './actions';
|
||||
import type { AccountSubject, AccountSubjectCategory } from './types';
|
||||
import { formatAccountLabel } from './types';
|
||||
|
||||
interface AccountSubjectSelectProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
/** 특정 대분류만 표시 */
|
||||
category?: AccountSubjectCategory;
|
||||
/** 특정 중분류만 표시 */
|
||||
subCategory?: string;
|
||||
/** 특정 부문만 표시 */
|
||||
departmentType?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** 빈 값(전체) 옵션 표시 여부 */
|
||||
showAllOption?: boolean;
|
||||
allOptionLabel?: string;
|
||||
/** 트리거 크기 */
|
||||
size?: 'default' | 'sm';
|
||||
/** value/onValueChange에 사용할 필드 (기본: code) */
|
||||
valueField?: 'code' | 'id';
|
||||
}
|
||||
|
||||
export function AccountSubjectSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
category,
|
||||
subCategory,
|
||||
departmentType,
|
||||
placeholder = '계정과목 선택',
|
||||
disabled = false,
|
||||
className,
|
||||
showAllOption = false,
|
||||
allOptionLabel = '전체',
|
||||
size = 'default',
|
||||
valueField = 'code',
|
||||
}: AccountSubjectSelectProps) {
|
||||
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const loadSubjects = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getAccountSubjects({
|
||||
selectable: true,
|
||||
isActive: true,
|
||||
category: category || undefined,
|
||||
subCategory: subCategory || undefined,
|
||||
departmentType: departmentType || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
setSubjects(result.data);
|
||||
}
|
||||
} catch {
|
||||
// 조회 실패 시 빈 목록 유지
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [category, subCategory, departmentType]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubjects();
|
||||
}, [loadSubjects]);
|
||||
|
||||
// subject에서 value로 사용할 필드 추출
|
||||
const getSubjectValue = useCallback(
|
||||
(s: AccountSubject) => (valueField === 'id' ? s.id : s.code),
|
||||
[valueField]
|
||||
);
|
||||
|
||||
// 선택된 계정과목 찾기
|
||||
const selectedSubject = useMemo(
|
||||
() => subjects.find((s) => getSubjectValue(s) === value),
|
||||
[subjects, value, getSubjectValue]
|
||||
);
|
||||
|
||||
// 트리거에 표시할 텍스트
|
||||
const displayLabel = useMemo(() => {
|
||||
if (isLoading) return '로딩 중...';
|
||||
if (value === 'all' && showAllOption) return allOptionLabel;
|
||||
if (selectedSubject) return formatAccountLabel(selectedSubject);
|
||||
return '';
|
||||
}, [isLoading, value, showAllOption, allOptionLabel, selectedSubject]);
|
||||
|
||||
const handleSelect = (subjectValue: string) => {
|
||||
onValueChange(subjectValue);
|
||||
setOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
const triggerClassName = size === 'sm' ? 'h-8 text-sm' : 'h-9 text-sm';
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
'w-full justify-between font-normal',
|
||||
triggerClassName,
|
||||
!displayLabel && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{displayLabel || placeholder}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] min-w-[280px] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter>
|
||||
<CommandInput
|
||||
placeholder="코드 또는 계정과목명 검색..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showAllOption && (
|
||||
<CommandItem
|
||||
value={allOptionLabel}
|
||||
onSelect={() => handleSelect('all')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === 'all' ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{allOptionLabel}
|
||||
</CommandItem>
|
||||
)}
|
||||
{subjects.map((subject) => {
|
||||
const subjectVal = getSubjectValue(subject);
|
||||
return (
|
||||
<CommandItem
|
||||
key={subject.id}
|
||||
value={`${subject.code} ${subject.name}`}
|
||||
onSelect={() => handleSelect(subjectVal)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === subjectVal ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground mr-1.5 font-mono text-xs">
|
||||
{subject.code}
|
||||
</span>
|
||||
<span>{subject.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계정과목 설정 팝업
|
||||
* 계정과목 설정 모달 (공용)
|
||||
*
|
||||
* - 계정과목 추가: 코드, 계정과목명, 분류 Select, 추가 버튼
|
||||
* - 검색: 검색 Input, 분류 필터 Select, 건수 표시
|
||||
* - 테이블: 코드 | 계정과목명 | 분류 | 상태(사용중/미사용 토글) | 작업(삭제)
|
||||
* - 테이블: 코드 | 계정과목명 | 분류 | 부문 | 상태(사용중/미사용 토글) | 작업(삭제)
|
||||
* - 기본 계정과목표 일괄 생성 버튼
|
||||
* - 버튼: 닫기
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Plus, Trash2, Loader2, Database } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -54,13 +55,16 @@ import {
|
||||
createAccountSubject,
|
||||
updateAccountSubjectStatus,
|
||||
deleteAccountSubject,
|
||||
seedDefaultAccountSubjects,
|
||||
} from './actions';
|
||||
import type { AccountSubject, AccountSubjectCategory } from './types';
|
||||
import {
|
||||
ACCOUNT_CATEGORY_OPTIONS,
|
||||
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
DEPARTMENT_TYPE_LABELS,
|
||||
} from './types';
|
||||
import type { DepartmentType } from './types';
|
||||
|
||||
interface AccountSubjectSettingModalProps {
|
||||
open: boolean;
|
||||
@@ -84,6 +88,7 @@ export function AccountSubjectSettingModal({
|
||||
// 데이터
|
||||
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSeeding, setIsSeeding] = useState(false);
|
||||
|
||||
// 삭제 확인
|
||||
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
|
||||
@@ -195,10 +200,40 @@ export function AccountSubjectSettingModal({
|
||||
}
|
||||
}, [deleteTarget, loadSubjects]);
|
||||
|
||||
// 기본 계정과목표 생성
|
||||
const handleSeedDefaults = useCallback(async () => {
|
||||
setIsSeeding(true);
|
||||
try {
|
||||
const result = await seedDefaultAccountSubjects();
|
||||
if (result.success) {
|
||||
const count = result.data?.inserted_count ?? 0;
|
||||
if (count > 0) {
|
||||
toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`);
|
||||
} else {
|
||||
toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.');
|
||||
}
|
||||
loadSubjects();
|
||||
} else {
|
||||
toast.error(result.error || '기본 계정과목 생성에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('기본 계정과목 생성 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSeeding(false);
|
||||
}
|
||||
}, [loadSubjects]);
|
||||
|
||||
// depth에 따른 들여쓰기
|
||||
const getIndentClass = (depth: number) => {
|
||||
if (depth === 1) return 'font-bold';
|
||||
if (depth === 2) return 'pl-4 font-medium';
|
||||
return 'pl-8';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">계정과목을 추가, 검색, 상태변경, 삭제합니다</DialogDescription>
|
||||
@@ -211,7 +246,7 @@ export function AccountSubjectSettingModal({
|
||||
label="코드"
|
||||
value={newCode}
|
||||
onChange={setNewCode}
|
||||
placeholder="코드"
|
||||
placeholder="예: 10100"
|
||||
/>
|
||||
<FormField
|
||||
label="계정과목명"
|
||||
@@ -273,9 +308,23 @@ export function AccountSubjectSettingModal({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredSubjects.length}개
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredSubjects.length}건
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 ml-auto"
|
||||
onClick={handleSeedDefaults}
|
||||
disabled={isSeeding}
|
||||
>
|
||||
{isSeeding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
기본 계정과목 생성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,30 +338,36 @@ export function AccountSubjectSettingModal({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">코드</TableHead>
|
||||
<TableHead className="w-[80px]">코드</TableHead>
|
||||
<TableHead>계정과목명</TableHead>
|
||||
<TableHead className="text-center w-[80px]">분류</TableHead>
|
||||
<TableHead className="text-center w-[100px]">상태</TableHead>
|
||||
<TableHead className="text-center w-[60px]">작업</TableHead>
|
||||
<TableHead className="text-center w-[70px]">분류</TableHead>
|
||||
<TableHead className="text-center w-[60px]">부문</TableHead>
|
||||
<TableHead className="text-center w-[90px]">상태</TableHead>
|
||||
<TableHead className="text-center w-[50px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
|
||||
계정과목이 없습니다.
|
||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground h-[100px]">
|
||||
계정과목이 없습니다. "기본 계정과목 생성" 버튼을 클릭하면 표준 계정과목표가 생성됩니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredSubjects.map((subject) => (
|
||||
<TableRow key={subject.id}>
|
||||
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
|
||||
<TableCell className="text-sm">{subject.name}</TableCell>
|
||||
<TableCell className={`text-sm ${getIndentClass(subject.depth)}`}>
|
||||
{subject.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ACCOUNT_CATEGORY_LABELS[subject.category]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant={subject.isActive ? 'default' : 'outline'}
|
||||
123
src/components/accounting/common/actions.ts
Normal file
123
src/components/accounting/common/actions.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use server';
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { AccountSubject, AccountSubjectApiData } from './types';
|
||||
import { transformAccountSubjectApi } from './types';
|
||||
|
||||
// ===== 계정과목 목록 조회 =====
|
||||
export async function getAccountSubjects(params?: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
subCategory?: string;
|
||||
departmentType?: string;
|
||||
depth?: number;
|
||||
isActive?: boolean;
|
||||
selectable?: boolean;
|
||||
}): Promise<ActionResult<AccountSubject[]>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects', {
|
||||
search: params?.search || undefined,
|
||||
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||
sub_category: params?.subCategory || undefined,
|
||||
department_type: params?.departmentType || undefined,
|
||||
depth: params?.depth,
|
||||
is_active: params?.isActive,
|
||||
selectable: params?.selectable,
|
||||
}),
|
||||
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
|
||||
errorMessage: '계정과목 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 추가 =====
|
||||
export async function createAccountSubject(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
subCategory?: string;
|
||||
parentCode?: string;
|
||||
depth?: number;
|
||||
departmentType?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
}): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
sub_category: data.subCategory || undefined,
|
||||
parent_code: data.parentCode || undefined,
|
||||
depth: data.depth ?? 3,
|
||||
department_type: data.departmentType || 'common',
|
||||
description: data.description || undefined,
|
||||
sort_order: data.sortOrder,
|
||||
},
|
||||
errorMessage: '계정과목 추가에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 수정 =====
|
||||
export async function updateAccountSubject(
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
category?: string;
|
||||
subCategory?: string;
|
||||
parentCode?: string;
|
||||
depth?: number;
|
||||
departmentType?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
sub_category: data.subCategory,
|
||||
parent_code: data.parentCode,
|
||||
depth: data.depth,
|
||||
department_type: data.departmentType,
|
||||
description: data.description,
|
||||
sort_order: data.sortOrder,
|
||||
},
|
||||
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: '계정과목 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 기본 계정과목표 일괄 생성 =====
|
||||
export async function seedDefaultAccountSubjects(): Promise<ActionResult<{ inserted_count: number }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects/seed-defaults'),
|
||||
method: 'POST',
|
||||
errorMessage: '기본 계정과목 생성에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
18
src/components/accounting/common/index.ts
Normal file
18
src/components/accounting/common/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
|
||||
export { AccountSubjectSelect } from './AccountSubjectSelect';
|
||||
export type {
|
||||
AccountSubject,
|
||||
AccountSubjectApiData,
|
||||
AccountSubjectCategory,
|
||||
AccountSubCategory,
|
||||
DepartmentType,
|
||||
} from './types';
|
||||
export {
|
||||
ACCOUNT_CATEGORY_OPTIONS,
|
||||
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
SUB_CATEGORY_LABELS,
|
||||
DEPARTMENT_TYPE_LABELS,
|
||||
transformAccountSubjectApi,
|
||||
formatAccountLabel,
|
||||
} from './types';
|
||||
118
src/components/accounting/common/types.ts
Normal file
118
src/components/accounting/common/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 계정과목 공용 타입 및 상수
|
||||
*
|
||||
* 모든 회계 모듈에서 공유하는 계정과목 관련 타입/상수 정의.
|
||||
* 기존 각 모듈별 ACCOUNT_SUBJECT_OPTIONS, AccountSubjectCategory 등을 대체.
|
||||
*/
|
||||
|
||||
// ===== 계정과목 분류 (대분류) =====
|
||||
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 AccountSubCategory =
|
||||
| 'current_asset'
|
||||
| 'fixed_asset'
|
||||
| 'current_liability'
|
||||
| 'long_term_liability'
|
||||
| 'capital'
|
||||
| 'sales_revenue'
|
||||
| 'other_revenue'
|
||||
| 'cogs'
|
||||
| 'selling_admin'
|
||||
| 'other_expense';
|
||||
|
||||
export const SUB_CATEGORY_LABELS: Record<AccountSubCategory, string> = {
|
||||
current_asset: '유동자산',
|
||||
fixed_asset: '비유동자산',
|
||||
current_liability: '유동부채',
|
||||
long_term_liability: '비유동부채',
|
||||
capital: '자본',
|
||||
sales_revenue: '매출',
|
||||
other_revenue: '영업외수익',
|
||||
cogs: '매출원가',
|
||||
selling_admin: '판매비와관리비',
|
||||
other_expense: '영업외비용',
|
||||
};
|
||||
|
||||
// ===== 부문 =====
|
||||
export type DepartmentType = 'common' | 'manufacturing' | 'admin';
|
||||
|
||||
export const DEPARTMENT_TYPE_LABELS: Record<DepartmentType, string> = {
|
||||
common: '공통',
|
||||
manufacturing: '제조',
|
||||
admin: '관리',
|
||||
};
|
||||
|
||||
// ===== 계정과목 인터페이스 =====
|
||||
export interface AccountSubject {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: AccountSubjectCategory;
|
||||
subCategory: string | null;
|
||||
parentCode: string | null;
|
||||
depth: number;
|
||||
departmentType: DepartmentType;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSubjectApiData {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
sub_category: string | null;
|
||||
parent_code: string | null;
|
||||
depth: number;
|
||||
department_type: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
code: apiData.code,
|
||||
name: apiData.name,
|
||||
category: apiData.category as AccountSubjectCategory,
|
||||
subCategory: apiData.sub_category,
|
||||
parentCode: apiData.parent_code,
|
||||
depth: apiData.depth ?? 3,
|
||||
departmentType: (apiData.department_type || 'common') as DepartmentType,
|
||||
description: apiData.description,
|
||||
sortOrder: apiData.sort_order ?? 0,
|
||||
isActive: Boolean(apiData.is_active),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 표시용 포맷 =====
|
||||
export function formatAccountLabel(subject: AccountSubject): string {
|
||||
return `[${subject.code}] ${subject.name}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo, type RefCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LayoutDashboard, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,29 +34,26 @@ import { ScheduleDetailModal, DetailModal } from './modals';
|
||||
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
|
||||
import { LazySection } from './LazySection';
|
||||
import { EmptySection } from './components';
|
||||
import { SummaryNavBar } from './SummaryNavBar';
|
||||
import { useSectionSummary } from './useSectionSummary';
|
||||
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
|
||||
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
|
||||
import {
|
||||
getMonthlyExpenseModalConfig,
|
||||
getCardManagementModalConfig,
|
||||
getCardManagementModalConfigWithData,
|
||||
getEntertainmentModalConfig,
|
||||
getWelfareModalConfig,
|
||||
getVatModalConfig,
|
||||
} from './modalConfigs';
|
||||
import { getCardManagementModalConfigWithData } from './modalConfigs';
|
||||
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
|
||||
import { toast } from 'sonner';
|
||||
import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation';
|
||||
|
||||
export function CEODashboard() {
|
||||
const router = useRouter();
|
||||
|
||||
// API 데이터 Hook (신규 6개는 백엔드 API 구현 전까지 비활성)
|
||||
// API 데이터 Hook
|
||||
const apiData = useCEODashboard({
|
||||
salesStatus: false,
|
||||
purchaseStatus: false,
|
||||
dailyProduction: false,
|
||||
unshipped: false,
|
||||
construction: false,
|
||||
dailyAttendance: false,
|
||||
salesStatus: true,
|
||||
purchaseStatus: true,
|
||||
dailyProduction: true,
|
||||
unshipped: true,
|
||||
construction: true,
|
||||
dailyAttendance: true,
|
||||
});
|
||||
|
||||
// TodayIssue API Hook (Phase 2)
|
||||
@@ -74,6 +71,27 @@ export function CEODashboard() {
|
||||
// Welfare API Hook (Phase 2)
|
||||
const welfareData = useWelfare();
|
||||
|
||||
// 대시보드 targeted refetch: CUD 후 stale 섹션만 갱신
|
||||
useEffect(() => {
|
||||
const refetchSection = (key: string) => {
|
||||
if (key === 'entertainment') entertainmentData.refetch();
|
||||
else if (key === 'welfare') welfareData.refetch();
|
||||
else apiData.refetchMap[key as DashboardSectionKey]?.();
|
||||
};
|
||||
const stale = consumeStaleSections();
|
||||
if (stale.length > 0) {
|
||||
for (const key of stale) refetchSection(key);
|
||||
}
|
||||
const handler = (e: Event) => {
|
||||
const sections = (e as CustomEvent).detail?.sections as string[] | undefined;
|
||||
if (sections) {
|
||||
for (const key of sections) refetchSection(key);
|
||||
}
|
||||
};
|
||||
window.addEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
|
||||
return () => window.removeEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
|
||||
}, [apiData.refetchMap, entertainmentData.refetch, welfareData.refetch]);
|
||||
|
||||
// Card Management Modal API Hook (Phase 3)
|
||||
const cardManagementModals = useCardManagementModals();
|
||||
|
||||
@@ -322,49 +340,37 @@ export function CEODashboard() {
|
||||
setCurrentModalCardId('cm2');
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
} else {
|
||||
toast.error('데이터를 불러올 수 없습니다');
|
||||
}
|
||||
} catch {
|
||||
// API 실패 시 fallback mock 데이터 사용
|
||||
const config = getCardManagementModalConfig('cm2');
|
||||
if (config) {
|
||||
setCurrentModalCardId('cm2');
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}
|
||||
toast.error('데이터를 불러올 수 없습니다');
|
||||
}
|
||||
}, [cardManagementModals]);
|
||||
|
||||
// 접대비 현황 카드 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
|
||||
// 접대비 현황 카드 클릭 - API 데이터로 모달 열기
|
||||
const handleEntertainmentCardClick = useCallback(async (cardId: string) => {
|
||||
// et_sales 카드는 별도 정적 config 사용 (매출 상세)
|
||||
if (cardId === 'et_sales') {
|
||||
const config = getEntertainmentModalConfig(cardId);
|
||||
if (config) {
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 리스크 카드 → API에서 상세 데이터 fetch, 반환값 직접 사용
|
||||
setCurrentModalCardId('entertainment_detail');
|
||||
const apiConfig = await entertainmentDetailData.refetch();
|
||||
const config = apiConfig ?? getEntertainmentModalConfig(cardId);
|
||||
if (config) {
|
||||
setDetailModalConfig(config);
|
||||
if (apiConfig) {
|
||||
setDetailModalConfig(apiConfig);
|
||||
setIsDetailModalOpen(true);
|
||||
} else {
|
||||
toast.error('데이터를 불러올 수 없습니다');
|
||||
}
|
||||
}, [entertainmentDetailData]);
|
||||
|
||||
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
|
||||
// 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
|
||||
const handleWelfareCardClick = useCallback(async () => {
|
||||
const apiConfig = await welfareDetailData.refetch();
|
||||
const config = apiConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
|
||||
setDetailModalConfig(config);
|
||||
setCurrentModalCardId('welfare_detail');
|
||||
setIsDetailModalOpen(true);
|
||||
}, [welfareDetailData, dashboardSettings.welfare.calculationType]);
|
||||
if (apiConfig) {
|
||||
setDetailModalConfig(apiConfig);
|
||||
setCurrentModalCardId('welfare_detail');
|
||||
setIsDetailModalOpen(true);
|
||||
} else {
|
||||
toast.error('데이터를 불러올 수 없습니다');
|
||||
}
|
||||
}, [welfareDetailData]);
|
||||
|
||||
// 신고기간 변경 시 API 재호출
|
||||
const handlePeriodChange = useCallback(async (periodValue: string) => {
|
||||
@@ -392,17 +398,19 @@ export function CEODashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기 (fallback: 정적 config)
|
||||
// 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기
|
||||
const handleVatClick = useCallback(async () => {
|
||||
setCurrentModalCardId('vat_detail');
|
||||
const apiConfig = await vatDetailData.refetch();
|
||||
const config = apiConfig ?? getVatModalConfig();
|
||||
// onPeriodChange 콜백 주입
|
||||
if (config.periodSelect) {
|
||||
config.periodSelect.onPeriodChange = handlePeriodChange;
|
||||
if (apiConfig) {
|
||||
if (apiConfig.periodSelect) {
|
||||
apiConfig.periodSelect.onPeriodChange = handlePeriodChange;
|
||||
}
|
||||
setDetailModalConfig(apiConfig);
|
||||
setIsDetailModalOpen(true);
|
||||
} else {
|
||||
toast.error('데이터를 불러올 수 없습니다');
|
||||
}
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}, [vatDetailData, handlePeriodChange]);
|
||||
|
||||
// 캘린더 일정 클릭 (기존 일정 수정)
|
||||
@@ -547,6 +555,26 @@ export function CEODashboard() {
|
||||
// 섹션 순서
|
||||
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
|
||||
|
||||
// 요약 네비게이션 바 훅
|
||||
const { summaries, activeSectionKey, sectionRefs, scrollToSection } = useSectionSummary({
|
||||
data,
|
||||
sectionOrder,
|
||||
dashboardSettings,
|
||||
});
|
||||
|
||||
// 섹션 ref 수집 콜백
|
||||
const setSectionRef = useCallback(
|
||||
(key: SectionKey): RefCallback<HTMLDivElement> =>
|
||||
(el) => {
|
||||
if (el) {
|
||||
sectionRefs.current.set(key, el);
|
||||
} else {
|
||||
sectionRefs.current.delete(key);
|
||||
}
|
||||
},
|
||||
[sectionRefs],
|
||||
);
|
||||
|
||||
// 섹션 렌더링 함수
|
||||
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
|
||||
switch (key) {
|
||||
@@ -761,8 +789,22 @@ export function CEODashboard() {
|
||||
}
|
||||
/>
|
||||
|
||||
<SummaryNavBar
|
||||
summaries={summaries}
|
||||
activeSectionKey={activeSectionKey}
|
||||
onChipClick={scrollToSection}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sectionOrder.map(renderDashboardSection)}
|
||||
{sectionOrder.map((key) => {
|
||||
const node = renderDashboardSection(key);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<div key={key} ref={setSectionRef(key)} data-section-key={key}>
|
||||
{node}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */}
|
||||
|
||||
255
src/components/business/CEODashboard/SummaryNavBar.tsx
Normal file
255
src/components/business/CEODashboard/SummaryNavBar.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SectionSummary, SummaryStatus } from './useSectionSummary';
|
||||
import type { SectionKey } from './types';
|
||||
|
||||
/** 상태별 점(dot) 색상 */
|
||||
const STATUS_DOT: Record<SummaryStatus, string> = {
|
||||
normal: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
danger: 'bg-red-500',
|
||||
};
|
||||
|
||||
/** 상태별 칩 배경색 (비활성) */
|
||||
const STATUS_BG: Record<SummaryStatus, string> = {
|
||||
normal: 'bg-background border-border',
|
||||
warning: 'bg-yellow-50 border-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-700',
|
||||
danger: 'bg-red-50 border-red-300 dark:bg-red-950/30 dark:border-red-700',
|
||||
};
|
||||
|
||||
/** 상태별 칩 배경색 (활성) */
|
||||
const STATUS_BG_ACTIVE: Record<SummaryStatus, string> = {
|
||||
normal: 'bg-accent border-primary/40',
|
||||
warning: 'bg-yellow-100 border-yellow-400 dark:bg-yellow-900/40 dark:border-yellow-600',
|
||||
danger: 'bg-red-100 border-red-400 dark:bg-red-900/40 dark:border-red-600',
|
||||
};
|
||||
|
||||
interface SummaryChipProps {
|
||||
summary: SectionSummary;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SummaryChip({ summary, isActive, onClick }: SummaryChipProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-medium whitespace-nowrap',
|
||||
'border transition-all duration-200 shrink-0',
|
||||
'hover:brightness-95 active:scale-[0.97]',
|
||||
isActive
|
||||
? cn(STATUS_BG_ACTIVE[summary.status], 'text-foreground shadow-sm')
|
||||
: cn(STATUS_BG[summary.status], 'text-muted-foreground'),
|
||||
)}
|
||||
>
|
||||
{/* 상태 점 (확대) */}
|
||||
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[summary.status])} />
|
||||
{/* 라벨 */}
|
||||
<span className="truncate max-w-[6rem]">{summary.label}</span>
|
||||
{/* 값 */}
|
||||
<span className={cn(
|
||||
'font-bold',
|
||||
summary.status === 'danger' && 'text-red-600 dark:text-red-400',
|
||||
summary.status === 'warning' && 'text-yellow-600 dark:text-yellow-400',
|
||||
)}>
|
||||
{summary.value}
|
||||
</span>
|
||||
{/* 활성 하단 바 */}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-[3px] rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const HEADER_BOTTOM = 100; // 헤더 하단 고정 위치 (px)
|
||||
const BAR_HEIGHT = 56; // 요약바 높이 (px) — 고령 친화 확대
|
||||
const SCROLL_STEP = 200; // 화살표 버튼 클릭 시 스크롤 이동량 (px)
|
||||
|
||||
interface SummaryNavBarProps {
|
||||
summaries: SectionSummary[];
|
||||
activeSectionKey: SectionKey | null;
|
||||
onChipClick: (key: SectionKey) => void;
|
||||
}
|
||||
|
||||
export function SummaryNavBar({ summaries, activeSectionKey, onChipClick }: SummaryNavBarProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [isFixed, setIsFixed] = useState(false);
|
||||
const [barRect, setBarRect] = useState({ left: 0, width: 0 });
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
// 스크롤 가능 여부 체크
|
||||
const updateScrollButtons = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||
}, []);
|
||||
|
||||
// sentinel 위치 감시: sentinel이 헤더 뒤로 지나가면 fixed 모드
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!sentinelRef.current) return;
|
||||
const rect = sentinelRef.current.getBoundingClientRect();
|
||||
const shouldFix = rect.top < HEADER_BOTTOM;
|
||||
setIsFixed(shouldFix);
|
||||
|
||||
if (shouldFix) {
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
const mainStyle = getComputedStyle(main);
|
||||
const pl = parseFloat(mainStyle.paddingLeft) || 0;
|
||||
const pr = parseFloat(mainStyle.paddingRight) || 0;
|
||||
setBarRect({
|
||||
left: mainRect.left + pl,
|
||||
width: mainRect.width - pl - pr,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 칩 영역 스크롤 상태 감시
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
updateScrollButtons();
|
||||
el.addEventListener('scroll', updateScrollButtons, { passive: true });
|
||||
const ro = new ResizeObserver(updateScrollButtons);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateScrollButtons);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [updateScrollButtons, summaries]);
|
||||
|
||||
// 활성 칩 자동 스크롤 into view
|
||||
useEffect(() => {
|
||||
if (!activeSectionKey || !scrollRef.current) return;
|
||||
const chipEl = scrollRef.current.querySelector(`[data-chip-key="${activeSectionKey}"]`) as HTMLElement | null;
|
||||
if (!chipEl) return;
|
||||
|
||||
const container = scrollRef.current;
|
||||
const chipLeft = chipEl.offsetLeft;
|
||||
const chipWidth = chipEl.offsetWidth;
|
||||
const containerWidth = container.offsetWidth;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
|
||||
if (chipLeft < scrollLeft + 50 || chipLeft + chipWidth > scrollLeft + containerWidth - 50) {
|
||||
container.scrollTo({
|
||||
left: chipLeft - containerWidth / 2 + chipWidth / 2,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [activeSectionKey]);
|
||||
|
||||
const handleChipClick = useCallback(
|
||||
(key: SectionKey) => {
|
||||
onChipClick(key);
|
||||
},
|
||||
[onChipClick],
|
||||
);
|
||||
|
||||
// 화살표 버튼 핸들러
|
||||
const scrollBy = useCallback((direction: 'left' | 'right') => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -SCROLL_STEP : SCROLL_STEP,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (summaries.length === 0) return null;
|
||||
|
||||
const arrowBtnClass = cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full shrink-0',
|
||||
'bg-muted/80 hover:bg-muted text-foreground',
|
||||
'border border-border shadow-sm',
|
||||
'transition-opacity duration-150',
|
||||
);
|
||||
|
||||
const barContent = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 좌측 화살표 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 항목"
|
||||
className={cn(arrowBtnClass, !canScrollLeft && 'opacity-0 pointer-events-none')}
|
||||
onClick={() => scrollBy('left')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* 칩 목록 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex items-center gap-2 overflow-x-auto flex-1"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{summaries.map((s) => (
|
||||
<div key={s.key} data-chip-key={s.key}>
|
||||
<SummaryChip
|
||||
summary={s}
|
||||
isActive={activeSectionKey === s.key}
|
||||
onClick={() => handleChipClick(s.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 우측 화살표 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 항목"
|
||||
className={cn(arrowBtnClass, !canScrollRight && 'opacity-0 pointer-events-none')}
|
||||
onClick={() => scrollBy('right')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* sentinel: 이 div가 헤더 뒤로 사라지면 fixed 모드 활성화 */}
|
||||
<div ref={sentinelRef} className="h-0 w-full" />
|
||||
|
||||
{/* fixed일 때 레이아웃 공간 유지용 spacer */}
|
||||
{isFixed && <div style={{ height: BAR_HEIGHT }} />}
|
||||
|
||||
{/* 실제 바 */}
|
||||
<div
|
||||
className="z-40 py-2.5 backdrop-blur-md bg-background/90 border-b border-border/50"
|
||||
style={
|
||||
isFixed
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: HEADER_BOTTOM,
|
||||
left: barRect.left,
|
||||
width: barRect.width,
|
||||
}
|
||||
: { position: 'relative' }
|
||||
}
|
||||
>
|
||||
{barContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +1,10 @@
|
||||
import type { CEODashboardData } from './types';
|
||||
|
||||
/* ============================================
|
||||
* 전체 Mock 데이터 주석 처리
|
||||
* API 연동 완료 후 이 파일 삭제 예정
|
||||
* 기존 mock 데이터는 git history에서 확인 가능
|
||||
* ============================================ */
|
||||
|
||||
// 빈 기본값 (타입 안전성 유지 — 필수 필드만)
|
||||
/**
|
||||
* Mock 데이터 제거 완료 — 빈 기본값만 유지
|
||||
* dev 페이지에서 import하므로 파일 유지
|
||||
*/
|
||||
export const mockData: CEODashboardData = {
|
||||
todayIssue: [],
|
||||
todayIssueList: [],
|
||||
};
|
||||
|
||||
/* ============================================
|
||||
* 아래는 주석 처리된 기존 Mock 데이터
|
||||
* ============================================
|
||||
|
||||
import type {
|
||||
SalesStatusData,
|
||||
PurchaseStatusData,
|
||||
DailyProductionData,
|
||||
UnshippedData,
|
||||
DailyAttendanceData,
|
||||
} from './types';
|
||||
|
||||
const _originalMockData: CEODashboardData = {
|
||||
todayIssue: [],
|
||||
todayIssueList: [],
|
||||
dailyReport: {
|
||||
date: '2026년 1월 5일 월요일',
|
||||
cards: [
|
||||
{ id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' },
|
||||
{ id: 'dr2', label: '미수금 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' },
|
||||
{ id: 'dr3', label: '미지급금 잔액', amount: 3050000000 },
|
||||
{ id: 'dr4', label: '당월 예상 지출 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'dr-cp1',
|
||||
type: 'success',
|
||||
message: '어제 3.5억원 출금했습니다. 최근 7일 평균 대비 2배 이상으로 점검이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '3.5억원 출금', color: 'red' },
|
||||
{ text: '점검이 필요', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dr-cp2',
|
||||
type: 'success',
|
||||
message: '어제 10.2억원이 입금되었습니다. 대한건설 선수금 입금이 주요 원인입니다.',
|
||||
highlights: [
|
||||
{ text: '10.2억원', color: 'green' },
|
||||
{ text: '입금', color: 'green' },
|
||||
{ text: '대한건설 선수금 입금', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dr-cp3',
|
||||
type: 'success',
|
||||
message: '총 현금성 자산이 300.2억원입니다. 월 운영비용 대비 18개월분이 확보되어 안정적입니다.',
|
||||
highlights: [
|
||||
{ text: '18개월분', color: 'blue' },
|
||||
{ text: '안정적', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
monthlyExpense: {
|
||||
cards: [
|
||||
{ id: 'me1', label: '매입', amount: 3050000000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me2', label: '카드', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me3', label: '발행어음', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me4', label: '총 예상 지출 합계', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'me-cp1',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.',
|
||||
highlights: [
|
||||
{ text: '전월 대비 15% 증가', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'me-cp2',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '예산을 12% 초과', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'me-cp3',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 전월 대비 8% 감소했습니다. {계정과목명} 비용이 줄었습니다.',
|
||||
highlights: [
|
||||
{ text: '전월 대비 8% 감소', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cardManagement: {
|
||||
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
|
||||
cards: [
|
||||
{ id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' },
|
||||
{ id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'cm-cp1',
|
||||
type: 'success',
|
||||
message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.',
|
||||
highlights: [
|
||||
{ text: '850만원', color: 'red' },
|
||||
{ text: '가지급금', color: 'red' },
|
||||
{ text: '연 4.6% 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp2',
|
||||
type: 'success',
|
||||
message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.',
|
||||
highlights: [
|
||||
{ text: '연 약 1,400만원의 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp3',
|
||||
type: 'success',
|
||||
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
|
||||
highlights: [
|
||||
{ text: '불인정 항목 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp4',
|
||||
type: 'success',
|
||||
message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.',
|
||||
highlights: [
|
||||
{ text: '주말 카드 사용 100만원 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
entertainment: {
|
||||
cards: [
|
||||
{ id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'et2', label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, previousLabel: '불인정 5건' },
|
||||
{ id: 'et3', label: '고액 결제', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
{ id: 'et4', label: '증빙 미비', amount: 3123000, previousLabel: '미증빙 5건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'et-cp1',
|
||||
type: 'success',
|
||||
message: '{1사분기} 접대비 사용 1,000만원 / 한도 4,012만원 (75%). 여유 있게 운영 중입니다.',
|
||||
highlights: [
|
||||
{ text: '1,000만원', color: 'green' },
|
||||
{ text: '4,012만원 (75%)', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp2',
|
||||
type: 'success',
|
||||
message: '접대비 한도 85% 도달. 잔여 한도 600만원입니다. 사용 계획을 점검해 주세요.',
|
||||
highlights: [
|
||||
{ text: '잔여 한도 600만원', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp3',
|
||||
type: 'error',
|
||||
message: '접대비 한도 초과 320만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.',
|
||||
highlights: [
|
||||
{ text: '320만원 발생', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp4',
|
||||
type: 'error',
|
||||
message: '접대비 사용 중 3건(45만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.',
|
||||
highlights: [
|
||||
{ text: '3건(45만원)', color: 'red' },
|
||||
{ text: '거래처 정보가 누락', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
welfare: {
|
||||
cards: [
|
||||
{ id: 'wf1', label: '비과세 한도 초과', amount: 3123000, previousLabel: '5건' },
|
||||
{ id: 'wf2', label: '사적 사용 의심', amount: 3123000, previousLabel: '5건' },
|
||||
{ id: 'wf3', label: '특정인 편중', amount: 3123000, previousLabel: '5건' },
|
||||
{ id: 'wf4', label: '항목별 한도 초과', amount: 3123000, previousLabel: '5건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'wf-cp1',
|
||||
type: 'success',
|
||||
message: '1인당 월 복리후생비 20만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.',
|
||||
highlights: [
|
||||
{ text: '1인당 월 복리후생비 20만원', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wf-cp2',
|
||||
type: 'error',
|
||||
message: '식대가 월 25만원으로 비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.',
|
||||
highlights: [
|
||||
{ text: '식대가 월 25만원으로', color: 'red' },
|
||||
{ text: '초과', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
receivable: {
|
||||
cards: [
|
||||
{
|
||||
id: 'rv1',
|
||||
label: '누적 미수금',
|
||||
amount: 30123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 60123000 },
|
||||
{ label: '입금', value: 30000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv2',
|
||||
label: '당월 미수금',
|
||||
amount: 10123000,
|
||||
},
|
||||
{
|
||||
id: 'rv3',
|
||||
label: '미수금 거래처',
|
||||
amount: 31,
|
||||
unit: '건',
|
||||
subItems: [
|
||||
{ label: '연체', value: '21건' },
|
||||
{ label: '악성채권', value: '11건' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv4',
|
||||
label: '미수금 Top 3',
|
||||
amount: 0,
|
||||
displayValue: '상세보기',
|
||||
},
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'rv-cp1',
|
||||
type: 'success',
|
||||
message: '90일 이상 장기 미수금 3건(2,500만원) 발생. 회수 조치가 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '90일 이상 장기 미수금 3건(2,500만원) 발생', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv-cp2',
|
||||
type: 'success',
|
||||
message: '(주)대한전자 미수금 1,500만원으로 전체의 35%를 차지합니다. 리스크 분산이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '(주)대한전자 미수금 1,500만원으로 전체의 35%를', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
detailButtonPath: '/ko/accounting/receivables-status',
|
||||
},
|
||||
debtCollection: {
|
||||
cards: [
|
||||
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
|
||||
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
|
||||
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
|
||||
{ id: 'dc4', label: '추심종료', amount: 280000000, subLabel: '10건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'dc-cp1',
|
||||
type: 'success',
|
||||
message: '(주)대한전자 건 지급명령 신청 완료. 법원 결정까지 약 2주 소요 예정입니다.',
|
||||
highlights: [{ text: '(주)대한전자 건 지급명령 신청 완료.', color: 'red' }],
|
||||
},
|
||||
{
|
||||
id: 'dc-cp2',
|
||||
type: 'success',
|
||||
message: '(주)삼성테크 건 회수 불가 판정. 대손 처리 검토가 필요합니다.',
|
||||
highlights: [{ text: '(주)삼성테크 건 회수 불가 판정.', color: 'red' }],
|
||||
},
|
||||
],
|
||||
detailButtonPath: '/ko/accounting/bad-debt-collection',
|
||||
},
|
||||
vat: {
|
||||
cards: [
|
||||
{ id: 'vat1', label: '매출세액', amount: 3050000000 },
|
||||
{ id: 'vat2', label: '매입세액', amount: 2050000000 },
|
||||
{ id: 'vat3', label: '예상 납부세액', amount: 110000000 },
|
||||
{ id: 'vat4', label: '세금계산서 미발행', amount: 3, unit: '건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'vat-cp1',
|
||||
type: 'success',
|
||||
message: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다. 설비투자에 따른 매입세액 증가가 주요 원인입니다.',
|
||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다.', color: 'red' }],
|
||||
},
|
||||
{
|
||||
id: 'vat-cp2',
|
||||
type: 'success',
|
||||
message: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다. 전기 대비 12.9% 증가했으며, 이는 매출 증가에 따른 정상적인 증가로 판단됩니다.',
|
||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다.', color: 'red' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
// ===== 신규 섹션 Mock 데이터 =====
|
||||
salesStatus: {
|
||||
cumulativeSales: 312300000,
|
||||
achievementRate: 94.5,
|
||||
yoyChange: 12.5,
|
||||
monthlySales: 312300000,
|
||||
monthlyTrend: [
|
||||
{ month: '8월', amount: 250000000 },
|
||||
{ month: '9월', amount: 280000000 },
|
||||
{ month: '10월', amount: 310000000 },
|
||||
{ month: '11월', amount: 290000000 },
|
||||
{ month: '12월', amount: 320000000 },
|
||||
{ month: '1월', amount: 300000000 },
|
||||
{ month: '2월', amount: 312300000 },
|
||||
],
|
||||
clientSales: [
|
||||
{ name: '대한건설', amount: 95000000 },
|
||||
{ name: '삼성테크', amount: 78000000 },
|
||||
{ name: '현대산업', amount: 62000000 },
|
||||
{ name: 'LG전자', amount: 45000000 },
|
||||
{ name: '기타', amount: 32300000 },
|
||||
],
|
||||
dailyItems: [
|
||||
{ date: '2026-02-01', client: '대한건설', item: '스크린 외', amount: 25000000, status: '입금완료' },
|
||||
{ date: '2026-02-03', client: '삼성테크', item: '슬루 외', amount: 18000000, status: '미입금' },
|
||||
{ date: '2026-02-05', client: '현대산업', item: '절곡 외', amount: 32000000, status: '입금완료' },
|
||||
{ date: '2026-02-07', client: 'LG전자', item: '스크린', amount: 15000000, status: '부분입금' },
|
||||
{ date: '2026-02-10', client: '대한건설', item: '슬루', amount: 28000000, status: '입금완료' },
|
||||
{ date: '2026-02-12', client: '삼성테크', item: '절곡', amount: 22000000, status: '미입금' },
|
||||
{ date: '2026-02-15', client: '현대산업', item: '스크린 외', amount: 35000000, status: '입금완료' },
|
||||
],
|
||||
dailyTotal: 312300000,
|
||||
},
|
||||
purchaseStatus: {
|
||||
cumulativePurchase: 312300000,
|
||||
unpaidAmount: 312300000,
|
||||
yoyChange: -12.5,
|
||||
monthlyTrend: [
|
||||
{ month: '8월', amount: 180000000 },
|
||||
{ month: '9월', amount: 200000000 },
|
||||
{ month: '10월', amount: 220000000 },
|
||||
{ month: '11월', amount: 195000000 },
|
||||
{ month: '12월', amount: 230000000 },
|
||||
{ month: '1월', amount: 210000000 },
|
||||
{ month: '2월', amount: 312300000 },
|
||||
],
|
||||
materialRatio: [
|
||||
{ name: '원자재', value: 55, percentage: 55, color: '#3b82f6' },
|
||||
{ name: '부자재', value: 35, percentage: 35, color: '#10b981' },
|
||||
{ name: '소모품', value: 10, percentage: 10, color: '#f59e0b' },
|
||||
],
|
||||
dailyItems: [
|
||||
{ date: '2026-02-01', supplier: '한국철강', item: '철판 외', amount: 45000000, status: '결제완료' },
|
||||
{ date: '2026-02-03', supplier: '삼성소재', item: '알루미늄', amount: 28000000, status: '미결제' },
|
||||
{ date: '2026-02-05', supplier: '현대자재', item: '볼트/너트', amount: 12000000, status: '결제완료' },
|
||||
{ date: '2026-02-08', supplier: 'LG화학', item: '도료 외', amount: 18000000, status: '부분결제' },
|
||||
{ date: '2026-02-10', supplier: '한국철강', item: '스테인리스', amount: 52000000, status: '미결제' },
|
||||
{ date: '2026-02-13', supplier: '삼성소재', item: '구리판', amount: 35000000, status: '결제완료' },
|
||||
],
|
||||
dailyTotal: 312300000,
|
||||
},
|
||||
dailyProduction: {
|
||||
date: '2026년 2월 23일 월요일',
|
||||
processes: [
|
||||
{
|
||||
processName: '스크린',
|
||||
totalWork: 10,
|
||||
todo: 10,
|
||||
inProgress: 10,
|
||||
completed: 10,
|
||||
urgent: 3,
|
||||
subLine: 2,
|
||||
regular: 5,
|
||||
workerCount: 8,
|
||||
workItems: [
|
||||
{ id: 'sp1', orderNo: 'SO-2026-001', client: '대한건설', product: '스크린 A형', quantity: 50, status: '진행중' },
|
||||
{ id: 'sp2', orderNo: 'SO-2026-002', client: '삼성테크', product: '스크린 B형', quantity: 30, status: '진행중' },
|
||||
{ id: 'sp3', orderNo: 'SO-2026-003', client: '현대산업', product: '스크린 C형', quantity: 20, status: '대기' },
|
||||
{ id: 'sp4', orderNo: 'SO-2026-004', client: 'LG전자', product: '스크린 D형', quantity: 40, status: '대기' },
|
||||
{ id: 'sp5', orderNo: 'SO-2026-005', client: '대한건설', product: '스크린 E형', quantity: 25, status: '완료' },
|
||||
],
|
||||
workers: [
|
||||
{ name: '김철수', assigned: 5, completed: 3, rate: 60 },
|
||||
{ name: '이영희', assigned: 4, completed: 4, rate: 100 },
|
||||
{ name: '박민수', assigned: 3, completed: 2, rate: 67 },
|
||||
{ name: '정수진', assigned: 3, completed: 1, rate: 33 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: '슬랫',
|
||||
totalWork: 10,
|
||||
todo: 10,
|
||||
inProgress: 10,
|
||||
completed: 10,
|
||||
urgent: 2,
|
||||
subLine: 3,
|
||||
regular: 5,
|
||||
workerCount: 6,
|
||||
workItems: [
|
||||
{ id: 'sl1', orderNo: 'SO-2026-010', client: '대한건설', product: '슬루 A형', quantity: 40, status: '진행중' },
|
||||
{ id: 'sl2', orderNo: 'SO-2026-011', client: '삼성테크', product: '슬루 B형', quantity: 25, status: '진행중' },
|
||||
{ id: 'sl3', orderNo: 'SO-2026-012', client: '현대산업', product: '슬루 C형', quantity: 35, status: '대기' },
|
||||
],
|
||||
workers: [
|
||||
{ name: '최동훈', assigned: 4, completed: 3, rate: 75 },
|
||||
{ name: '강미영', assigned: 3, completed: 2, rate: 67 },
|
||||
{ name: '윤상호', assigned: 3, completed: 3, rate: 100 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: '절곡',
|
||||
totalWork: 10,
|
||||
todo: 10,
|
||||
inProgress: 10,
|
||||
completed: 10,
|
||||
urgent: 1,
|
||||
subLine: 2,
|
||||
regular: 7,
|
||||
workerCount: 5,
|
||||
workItems: [
|
||||
{ id: 'jg1', orderNo: 'SO-2026-020', client: '현대산업', product: '절곡 A형', quantity: 60, status: '진행중' },
|
||||
{ id: 'jg2', orderNo: 'SO-2026-021', client: 'LG전자', product: '절곡 B형', quantity: 45, status: '대기' },
|
||||
{ id: 'jg3', orderNo: 'SO-2026-022', client: '대한건설', product: '절곡 C형', quantity: 30, status: '완료' },
|
||||
],
|
||||
workers: [
|
||||
{ name: '한지원', assigned: 4, completed: 4, rate: 100 },
|
||||
{ name: '서준혁', assigned: 3, completed: 2, rate: 67 },
|
||||
],
|
||||
},
|
||||
],
|
||||
shipment: {
|
||||
expectedAmount: 150000000,
|
||||
expectedCount: 12,
|
||||
actualAmount: 120000000,
|
||||
actualCount: 9,
|
||||
},
|
||||
},
|
||||
unshipped: {
|
||||
items: [
|
||||
{ id: 'us1', portNo: 'P-2026-001', siteName: '강남 현장', orderClient: '대한건설', dueDate: '2026-02-25', daysLeft: 2 },
|
||||
{ id: 'us2', portNo: 'P-2026-002', siteName: '서초 현장', orderClient: '삼성테크', dueDate: '2026-02-26', daysLeft: 3 },
|
||||
{ id: 'us3', portNo: 'P-2026-003', siteName: '판교 현장', orderClient: '현대산업', dueDate: '2026-02-27', daysLeft: 4 },
|
||||
{ id: 'us4', portNo: 'P-2026-004', siteName: '송도 현장', orderClient: 'LG전자', dueDate: '2026-02-28', daysLeft: 5 },
|
||||
{ id: 'us5', portNo: 'P-2026-005', siteName: '마포 현장', orderClient: '대한건설', dueDate: '2026-03-01', daysLeft: 6 },
|
||||
{ id: 'us6', portNo: 'P-2026-006', siteName: '영등포 현장', orderClient: '삼성테크', dueDate: '2026-03-03', daysLeft: 8 },
|
||||
{ id: 'us7', portNo: 'P-2026-007', siteName: '용산 현장', orderClient: '현대산업', dueDate: '2026-03-05', daysLeft: 10 },
|
||||
],
|
||||
},
|
||||
constructionData: {
|
||||
thisMonth: 15,
|
||||
completed: 15,
|
||||
items: [
|
||||
{ id: 'cs1', siteName: '강남 현장', client: '대한건설', startDate: '2026-02-01', endDate: '2026-02-28', progress: 85, status: '진행중' },
|
||||
{ id: 'cs2', siteName: '서초 현장', client: '삼성테크', startDate: '2026-02-05', endDate: '2026-03-05', progress: 60, status: '진행중' },
|
||||
{ id: 'cs3', siteName: '판교 현장', client: '현대산업', startDate: '2026-02-10', endDate: '2026-03-10', progress: 40, status: '진행중' },
|
||||
{ id: 'cs4', siteName: '송도 현장', client: 'LG전자', startDate: '2026-03-01', endDate: '2026-03-30', progress: 0, status: '예정' },
|
||||
{ id: 'cs5', siteName: '마포 현장', client: '대한건설', startDate: '2026-01-15', endDate: '2026-02-15', progress: 100, status: '완료' },
|
||||
],
|
||||
},
|
||||
dailyAttendance: {
|
||||
present: 10,
|
||||
onLeave: 10,
|
||||
late: 10,
|
||||
absent: 10,
|
||||
employees: [
|
||||
{ id: 'att1', department: '생산부', position: '과장', name: '김철수', status: '출근' },
|
||||
{ id: 'att2', department: '영업부', position: '대리', name: '이영희', status: '출근' },
|
||||
{ id: 'att3', department: '관리부', position: '사원', name: '박민수', status: '휴가' },
|
||||
{ id: 'att4', department: '생산부', position: '부장', name: '정수진', status: '지각' },
|
||||
{ id: 'att5', department: '영업부', position: '과장', name: '최동훈', status: '출근' },
|
||||
{ id: 'att6', department: '관리부', position: '대리', name: '강미영', status: '결근' },
|
||||
{ id: 'att7', department: '생산부', position: '사원', name: '윤상호', status: '출근' },
|
||||
],
|
||||
},
|
||||
calendarSchedules: [
|
||||
{
|
||||
id: 'sch1',
|
||||
title: '제목',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-04',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
type: 'schedule',
|
||||
department: '부서명',
|
||||
},
|
||||
{
|
||||
id: 'sch2',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
type: 'schedule',
|
||||
personName: '홍길동',
|
||||
},
|
||||
{
|
||||
id: 'sch3',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
type: 'order',
|
||||
department: '부서명',
|
||||
},
|
||||
{
|
||||
id: 'sch4',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
startTime: '12:35',
|
||||
type: 'construction',
|
||||
personName: '홍길동',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
============================================ */
|
||||
@@ -32,7 +32,7 @@ export interface CardManagementModalData {
|
||||
|
||||
/**
|
||||
* API 데이터를 사용하여 모달 설정을 동적으로 생성
|
||||
* 데이터가 없는 경우 fallback 설정 사용
|
||||
* 데이터가 없는 경우 null 반환 (mock fallback 제거)
|
||||
*/
|
||||
export function getCardManagementModalConfigWithData(
|
||||
cardId: string,
|
||||
@@ -40,297 +40,26 @@ export function getCardManagementModalConfigWithData(
|
||||
): DetailModalConfig | null {
|
||||
switch (cardId) {
|
||||
case 'cm1':
|
||||
if (data.cm1Data) {
|
||||
return transformCm1ModalConfig(data.cm1Data);
|
||||
}
|
||||
return getCardManagementModalConfig(cardId);
|
||||
return data.cm1Data ? transformCm1ModalConfig(data.cm1Data) : null;
|
||||
|
||||
case 'cm2':
|
||||
if (data.cm2Data) {
|
||||
return transformCm2ModalConfig(data.cm2Data);
|
||||
}
|
||||
return getCardManagementModalConfig(cardId);
|
||||
return data.cm2Data ? transformCm2ModalConfig(data.cm2Data) : null;
|
||||
|
||||
case 'cm3':
|
||||
if (data.cm3Data) {
|
||||
return transformCm3ModalConfig(data.cm3Data);
|
||||
}
|
||||
return getCardManagementModalConfig(cardId);
|
||||
return data.cm3Data ? transformCm3ModalConfig(data.cm3Data) : null;
|
||||
|
||||
case 'cm4':
|
||||
if (data.cm4Data) {
|
||||
return transformCm4ModalConfig(data.cm4Data);
|
||||
}
|
||||
return getCardManagementModalConfig(cardId);
|
||||
return data.cm4Data ? transformCm4ModalConfig(data.cm4Data) : null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Fallback 모달 설정 (API 데이터 없을 때 사용)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Fallback: 정적 목업 데이터 기반 모달 설정
|
||||
* API 데이터가 없을 때 사용
|
||||
* Fallback 모달 설정 (mock 제거 완료 — null 반환)
|
||||
* API 데이터가 없을 때 모달을 열지 않음
|
||||
*/
|
||||
export function getCardManagementModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
cm1: {
|
||||
title: '카드 사용 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 카드 사용', value: 30123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
|
||||
{ label: '미정리 건수', value: '5건' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 카드 사용 추이',
|
||||
data: [
|
||||
{ name: '7월', value: 28000000 },
|
||||
{ name: '8월', value: 32000000 },
|
||||
{ name: '9월', value: 27000000 },
|
||||
{ name: '10월', value: 35000000 },
|
||||
{ name: '11월', value: 29000000 },
|
||||
{ name: '12월', value: 30123000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 카드 사용 비율',
|
||||
data: [
|
||||
{ name: '대표이사', value: 15000000, percentage: 50, color: '#60A5FA' },
|
||||
{ name: '경영지원팀', value: 9000000, percentage: 30, color: '#34D399' },
|
||||
{ name: '영업팀', value: 6123000, percentage: 20, color: '#FBBF24' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '카드 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-05 18:30', store: '스타벅스 강남점', amount: 45000, usageType: '복리후생비' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-04 12:15', store: '한식당', amount: 350000, usageType: '접대비' },
|
||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-03 14:20', store: '오피스디포', amount: 125000, usageType: '소모품비' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-02 19:45', store: '골프장', amount: 850000, usageType: '미설정' },
|
||||
{ cardName: '법인카드3', user: '영업팀', date: '2026-01-02 11:30', store: 'GS칼텍스', amount: 80000, usageType: '교통비' },
|
||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-01 16:00', store: '이마트', amount: 230000, usageType: '미설정' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-30 20:30', store: '백화점', amount: 1500000, usageType: '미설정' },
|
||||
{ cardName: '법인카드3', user: '영업팀', date: '2025-12-29 09:15', store: '커피빈', amount: 32000, usageType: '복리후생비' },
|
||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2025-12-28 13:45', store: '문구점', amount: 55000, usageType: '소모품비' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-27 21:00', store: '호텔', amount: 450000, usageType: '미설정' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '대표이사', label: '대표이사' },
|
||||
{ value: '경영지원팀', label: '경영지원팀' },
|
||||
{ value: '영업팀', label: '영업팀' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'usageType',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '미설정', label: '미설정' },
|
||||
{ value: '복리후생비', label: '복리후생비' },
|
||||
{ value: '접대비', label: '접대비' },
|
||||
{ value: '소모품비', label: '소모품비' },
|
||||
{ value: '교통비', label: '교통비' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 30123000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P52: 가지급금 상세
|
||||
cm2: {
|
||||
title: '가지급금 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '가지급금 합계', value: '4.5억원' },
|
||||
{ label: '가지급금 총액', value: 6000000, unit: '원' },
|
||||
{ label: '건수', value: '10건' },
|
||||
],
|
||||
reviewCards: {
|
||||
title: '가지급금 검토 필요',
|
||||
cards: [
|
||||
{ label: '카드', amount: 3123000, subLabel: '미정리 5건' },
|
||||
{ label: '경조사', amount: 3123000, subLabel: '미증빙 5건' },
|
||||
{ label: '상품권', amount: 3123000, subLabel: '미증빙 5건' },
|
||||
{ label: '접대비', amount: 3123000, subLabel: '미증빙 5건' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '가지급금 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '발생일', align: 'center' },
|
||||
{ key: 'classification', label: '분류', align: 'center' },
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||
{ key: 'response', label: '대응', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미정리' },
|
||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '미증빙' },
|
||||
{ date: '2025-12-12', classification: '경조사', category: '계좌명', amount: 1000000, response: '미증빙' },
|
||||
{ date: '2025-12-12', classification: '상품권', category: '계좌명', amount: 1000000, response: '미증빙' },
|
||||
{ date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '주말 카드 사용' },
|
||||
{ date: '2025-12-12', classification: '접대비', category: '카드명', amount: 1000000, response: '접대비 불인정' },
|
||||
{ date: '2025-12-12', classification: '카드', category: '카드명', amount: 1000000, response: '불인정 가맹점(귀금속)' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'classification',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '카드', label: '카드' },
|
||||
{ value: '경조사', label: '경조사' },
|
||||
{ value: '상품권', label: '상품권' },
|
||||
{ value: '접대비', label: '접대비' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'all', label: '정렬' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
{ value: 'latest', label: '최신순' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
cm3: {
|
||||
title: '법인세 예상 가중 상세',
|
||||
summaryCards: [
|
||||
{ label: '법인세 예상 증가', value: 3123000, unit: '원' },
|
||||
{ label: '인정 이자', value: 6000000, unit: '원' },
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정이자', value: 6000000, unit: '원' },
|
||||
],
|
||||
comparisonSection: {
|
||||
leftBox: {
|
||||
title: '없을때 법인세',
|
||||
items: [
|
||||
{ label: '과세표준', value: '3억원' },
|
||||
{ label: '법인세', value: 50970000, unit: '원' },
|
||||
],
|
||||
borderColor: 'orange',
|
||||
},
|
||||
rightBox: {
|
||||
title: '있을때 법인세',
|
||||
items: [
|
||||
{ label: '과세표준', value: '3.06억원' },
|
||||
{ label: '법인세', value: 54093000, unit: '원' },
|
||||
],
|
||||
borderColor: 'blue',
|
||||
},
|
||||
vsLabel: '법인세 예상 증가',
|
||||
vsValue: 3123000,
|
||||
vsSubLabel: '법인 세율 -12.5%',
|
||||
},
|
||||
referenceTable: {
|
||||
title: '법인세 과세표준 (2024년 기준)',
|
||||
columns: [
|
||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
||||
{ key: 'rate', label: '세율', align: 'center' },
|
||||
{ key: 'formula', label: '계산식', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ bracket: '2억원 이하', rate: '9%', formula: '과세표준 × 9%' },
|
||||
{ bracket: '2억원 초과 ~ 200억원 이하', rate: '19%', formula: '1,800만원 + (2억원 초과분 × 19%)' },
|
||||
{ bracket: '200억원 초과 ~ 3,000억원 이하', rate: '21%', formula: '37.62억원 + (200억원 초과분 × 21%)' },
|
||||
{ bracket: '3,000억원 초과', rate: '24%', formula: '625.62억원 + (3,000억원 초과분 × 24%)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
cm4: {
|
||||
title: '대표자 종합소득세 예상 가중 상세',
|
||||
summaryCards: [
|
||||
{ label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' },
|
||||
{ label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
|
||||
],
|
||||
comparisonSection: {
|
||||
leftBox: {
|
||||
title: '가지급금 인정이자가 반영된 종합소득세',
|
||||
items: [
|
||||
{ label: '현재 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
||||
{ label: '현재 적용 세율', value: '19%' },
|
||||
{ label: '현재 예상 세액', value: 10000000, unit: '원' },
|
||||
],
|
||||
borderColor: 'orange',
|
||||
},
|
||||
rightBox: {
|
||||
title: '가지급금 인정이자가 정리된 종합소득세',
|
||||
items: [
|
||||
{ label: '가지급금 정리 시 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
||||
{ label: '가지급금 정리 시 적용 세율', value: '19%' },
|
||||
{ label: '가지급금 정리 시 예상 세액', value: 10000000, unit: '원' },
|
||||
],
|
||||
borderColor: 'blue',
|
||||
},
|
||||
vsLabel: '종합소득세 예상 절감',
|
||||
vsValue: 3123000,
|
||||
vsSubLabel: '감소 세금 -12.5%',
|
||||
vsBreakdown: [
|
||||
{ label: '종합소득세', value: -2000000, unit: '원' },
|
||||
{ label: '지방소득세', value: -200000, unit: '원' },
|
||||
{ label: '4대 보험', value: -1000000, unit: '원' },
|
||||
],
|
||||
},
|
||||
referenceTable: {
|
||||
title: '종합소득세 과세표준 (2024년 기준)',
|
||||
columns: [
|
||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
||||
{ key: 'rate', label: '세율', align: 'center' },
|
||||
{ key: 'deduction', label: '누진공제', align: 'right' },
|
||||
{ key: 'formula', label: '계산식', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ bracket: '1,400만원 이하', rate: '6%', deduction: '-', formula: '과세표준 × 6%' },
|
||||
{ bracket: '1,400만원 초과 ~ 5,000만원 이하', rate: '15%', deduction: '126만원', formula: '과세표준 × 15% - 126만원' },
|
||||
{ bracket: '5,000만원 초과 ~ 8,800만원 이하', rate: '24%', deduction: '576만원', formula: '과세표준 × 24% - 576만원' },
|
||||
{ bracket: '8,800만원 초과 ~ 1.5억원 이하', rate: '35%', deduction: '1,544만원', formula: '과세표준 × 35% - 1,544만원' },
|
||||
{ bracket: '1.5억원 초과 ~ 3억원 이하', rate: '38%', deduction: '1,994만원', formula: '과세표준 × 38% - 1,994만원' },
|
||||
{ bracket: '3억원 초과 ~ 5억원 이하', rate: '40%', deduction: '2,594만원', formula: '과세표준 × 40% - 2,594만원' },
|
||||
{ bracket: '5억원 초과 ~ 10억원 이하', rate: '42%', deduction: '3,594만원', formula: '과세표준 × 42% - 3,594만원' },
|
||||
{ bracket: '10억원 초과', rate: '45%', deduction: '6,594만원', formula: '과세표준 × 45% - 6,594만원' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
export function getCardManagementModalConfig(_cardId: string): DetailModalConfig | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,243 +1,10 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 접대비 상세 공통 모달 config (et2, et3, et4 공통)
|
||||
*/
|
||||
const entertainmentDetailConfig: DetailModalConfig = {
|
||||
title: '접대비 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
// 첫 번째 줄: 당해년도
|
||||
{ label: '당해년도 접대비 총 한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 초과 금액', value: 0, unit: '원' },
|
||||
],
|
||||
reviewCards: {
|
||||
title: '접대비 검토 필요',
|
||||
cards: [
|
||||
{ label: '주말/심야', amount: 3123000, subLabel: '미증빙 5건' },
|
||||
{ label: '기피업종 (유흥, 귀금속 등)', amount: 3123000, subLabel: '불인정 5건' },
|
||||
{ label: '고액 결제', amount: 3123000, subLabel: '미증빙 5건' },
|
||||
{ label: '증빙 미비', amount: 3123000, subLabel: '미증빙 5건' },
|
||||
],
|
||||
},
|
||||
barChart: {
|
||||
title: '월별 접대비 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 3500000 },
|
||||
{ name: '2월', value: 4200000 },
|
||||
{ name: '3월', value: 2300000 },
|
||||
{ name: '4월', value: 3800000 },
|
||||
{ name: '5월', value: 4500000 },
|
||||
{ name: '6월', value: 3200000 },
|
||||
{ name: '7월', value: 2800000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 접대비 사용 비율',
|
||||
data: [
|
||||
{ name: '홍길동', value: 15000000, percentage: 53, color: '#60A5FA' },
|
||||
{ name: '김철수', value: 10000000, percentage: 31, color: '#34D399' },
|
||||
{ name: '이영희', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
{ name: '기타', value: 2000000, percentage: 6, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '월별 접대비 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '심야 카드 사용' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '미증빙' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '고액 결제' },
|
||||
{ cardName: '카드명', user: '김철수', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, content: '불인정 가맹점 (귀금속)' },
|
||||
{ cardName: '카드명', user: '이영희', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, content: '접대비 불인정' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
{ value: '김철수', label: '김철수' },
|
||||
{ value: '이영희', label: '이영희' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '주말/심야', label: '주말/심야' },
|
||||
{ value: '기피업종', label: '기피업종' },
|
||||
{ value: '고액 결제', label: '고액 결제' },
|
||||
{ value: '증빙 미비', label: '증빙 미비' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
// 접대비 손금한도 계산 - 기본한도 / 수입금액별 추가한도
|
||||
referenceTables: [
|
||||
{
|
||||
title: '접대비 손금한도 계산 - 기본한도',
|
||||
columns: [
|
||||
{ key: 'type', label: '법인 유형', align: 'left' },
|
||||
{ key: 'annualLimit', label: '연간 기본한도', align: 'right' },
|
||||
{ key: 'monthlyLimit', label: '월 환산', align: 'right' },
|
||||
],
|
||||
data: [
|
||||
{ type: '일반법인', annualLimit: '12,000,000원', monthlyLimit: '1,000,000원' },
|
||||
{ type: '중소기업', annualLimit: '36,000,000원', monthlyLimit: '3,000,000원' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '수입금액별 추가한도',
|
||||
columns: [
|
||||
{ key: 'range', label: '수입금액 구간', align: 'left' },
|
||||
{ key: 'formula', label: '추가한도 계산식', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ range: '100억원 이하', formula: '수입금액 × 0.2%' },
|
||||
{ range: '100억 초과 ~ 500억 이하', formula: '2,000만원 + (수입금액 - 100억) × 0.1%' },
|
||||
{ range: '500억원 초과', formula: '6,000만원 + (수입금액 - 500억) × 0.03%' },
|
||||
],
|
||||
},
|
||||
],
|
||||
// 접대비 계산
|
||||
calculationCards: {
|
||||
title: '접대비 계산',
|
||||
cards: [
|
||||
{ label: '중소기업 연간 기본한도', value: 36000000 },
|
||||
{ label: '당해년도 수입금액별 추가한도', value: 16000000, operator: '+' },
|
||||
{ label: '당해년도 접대비 총 한도', value: 52000000, operator: '=' },
|
||||
],
|
||||
},
|
||||
// 접대비 현황 (분기별)
|
||||
quarterlyTable: {
|
||||
title: '접대비 현황',
|
||||
rows: [
|
||||
{ label: '한도금액', q1: 13000000, q2: 13000000, q3: 13000000, q4: 13000000, total: 52000000 },
|
||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 접대비 현황 모달 설정
|
||||
* et_sales: 당해 매출 상세
|
||||
* et_limit, et_remaining, et_used: 접대비 상세 (공통)
|
||||
* API 연동 완료 — useEntertainmentDetail hook이 실제 데이터 반환
|
||||
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||
*/
|
||||
export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
et_sales: {
|
||||
title: '당해 매출 상세',
|
||||
summaryCards: [
|
||||
{ label: '당해년도 매출', value: 600000000, unit: '원' },
|
||||
{ label: '전년 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '당월 매출', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 매출 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 85000000 },
|
||||
{ name: '2월', value: 92000000 },
|
||||
{ name: '3월', value: 78000000 },
|
||||
{ name: '4월', value: 95000000 },
|
||||
{ name: '5월', value: 88000000 },
|
||||
{ name: '6월', value: 102000000 },
|
||||
{ name: '7월', value: 60000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
horizontalBarChart: {
|
||||
title: '당해년도 거래처별 매출',
|
||||
data: [
|
||||
{ name: '(주)세우', value: 120000000 },
|
||||
{ name: '대한건설', value: 95000000 },
|
||||
{ name: '삼성테크', value: 78000000 },
|
||||
{ name: '현대상사', value: 65000000 },
|
||||
{ name: '기타', value: 42000000 },
|
||||
],
|
||||
color: '#60A5FA',
|
||||
},
|
||||
table: {
|
||||
title: '일별 매출 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '매출일', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'amount', label: '매출금액', align: 'right', format: 'currency' },
|
||||
{ key: 'type', label: '매출유형', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부품 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '공사 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '상품 매출', label: '상품 매출' },
|
||||
{ value: '부품 매출', label: '부품 매출' },
|
||||
{ value: '공사 매출', label: '공사 매출' },
|
||||
{ value: '임대 수익', label: '임대 수익' },
|
||||
{ value: '기타 매출', label: '기타 매출' },
|
||||
{ value: '미설정', label: '미설정' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// D1.7 리스크감지형 카드 ID → 접대비 상세 모달
|
||||
et_weekend: entertainmentDetailConfig,
|
||||
et_prohibited: entertainmentDetailConfig,
|
||||
et_high_amount: entertainmentDetailConfig,
|
||||
et_no_receipt: entertainmentDetailConfig,
|
||||
// 레거시 카드 ID (하위 호환)
|
||||
et_limit: entertainmentDetailConfig,
|
||||
et_remaining: entertainmentDetailConfig,
|
||||
et_used: entertainmentDetailConfig,
|
||||
et1: entertainmentDetailConfig,
|
||||
et2: entertainmentDetailConfig,
|
||||
et3: entertainmentDetailConfig,
|
||||
et4: entertainmentDetailConfig,
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
export function getEntertainmentModalConfig(_cardId: string): DetailModalConfig | null {
|
||||
return null;
|
||||
}
|
||||
@@ -1,269 +1,10 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 당월 예상 지출 모달 설정 (D1.7 기획서 P48-51 반영)
|
||||
* 당월 예상 지출 모달 설정
|
||||
* API 연동 완료 — useMonthlyExpenseDetail hook이 실제 데이터 반환
|
||||
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||
*/
|
||||
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
// P48: 매입 상세
|
||||
me1: {
|
||||
title: '매입 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '매입', value: 3123000, unit: '원' },
|
||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
],
|
||||
barChart: {
|
||||
title: '매입 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 45000000 },
|
||||
{ name: '2월', value: 52000000 },
|
||||
{ name: '3월', value: 48000000 },
|
||||
{ name: '4월', value: 61000000 },
|
||||
{ name: '5월', value: 55000000 },
|
||||
{ name: '6월', value: 58000000 },
|
||||
{ name: '7월', value: 50000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '자재 유형별 구매 비율',
|
||||
data: [
|
||||
{ name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||
{ name: '부자재', value: 35000000, percentage: 35, color: '#FBBF24' },
|
||||
{ name: '포장재', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 매입 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '매입일', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'amount', label: '매입금액', align: 'right', format: 'currency' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
{ date: '2025-12-01', vendor: '회사명', amount: 11000000 },
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P49: 카드 상세
|
||||
me2: {
|
||||
title: '카드 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '카드 사용', value: 6000000, unit: '원' },
|
||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '건수', value: '10건' },
|
||||
],
|
||||
barChart: {
|
||||
title: '카드 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 4500000 },
|
||||
{ name: '2월', value: 5200000 },
|
||||
{ name: '3월', value: 4800000 },
|
||||
{ name: '4월', value: 6100000 },
|
||||
{ name: '5월', value: 5500000 },
|
||||
{ name: '6월', value: 5800000 },
|
||||
{ name: '7월', value: 6000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 카드 사용 비율',
|
||||
data: [
|
||||
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||
{ name: '김영희', value: 35000000, percentage: 35, color: '#FBBF24' },
|
||||
{ name: '이정현', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 카드 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '계정과목', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
|
||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
|
||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
{ cardName: '홍길동', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
{ value: '김영희', label: '김영희' },
|
||||
{ value: '이정현', label: '이정현' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P50: 발행어음 상세
|
||||
me3: {
|
||||
title: '발행어음 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
presets: ['당해년도', '전전월', '전월', '당월', '어제'],
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '발행어음', value: 3123000, unit: '원' },
|
||||
{ label: '이전 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
],
|
||||
barChart: {
|
||||
title: '발행어음 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 2000000 },
|
||||
{ name: '2월', value: 2500000 },
|
||||
{ name: '3월', value: 2200000 },
|
||||
{ name: '4월', value: 2800000 },
|
||||
{ name: '5월', value: 2600000 },
|
||||
{ name: '6월', value: 3000000 },
|
||||
{ name: '7월', value: 3123000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '거래처별 발행어음',
|
||||
data: [
|
||||
{ name: '거래처1', value: 50000000, percentage: 45, color: '#60A5FA' },
|
||||
{ name: '거래처2', value: 35000000, percentage: 32, color: '#FBBF24' },
|
||||
{ name: '거래처3', value: 20000000, percentage: 18, color: '#F87171' },
|
||||
{ name: '거래처4', value: 6000000, percentage: 5, color: '#34D399' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 발행어음 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
|
||||
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
|
||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||
{ key: 'status', label: '상태', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'vendor',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '회사명', label: '회사명' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '보관중', label: '보관중' },
|
||||
{ value: '만기임박', label: '만기임박' },
|
||||
{ value: '만기경과', label: '만기경과' },
|
||||
{ value: '결제완료', label: '결제완료' },
|
||||
{ value: '부도', label: '부도' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// P51: 당월 지출 예상 상세
|
||||
me4: {
|
||||
title: '당월 지출 예상 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 지출 예상', value: 6000000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '총 계좌 잔액', value: 10000000, unit: '원' },
|
||||
],
|
||||
table: {
|
||||
title: '당월 지출 승인 내역서',
|
||||
columns: [
|
||||
{ key: 'paymentDate', label: '예상 지급일', align: 'center' },
|
||||
{ key: 'item', label: '항목', align: 'left' },
|
||||
{ key: 'amount', label: '지출금액', align: 'right', format: 'currency', highlightColor: 'red' },
|
||||
{ key: 'vendor', label: '거래처', align: 'center' },
|
||||
{ key: 'account', label: '계좌', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '(발행 어음) 123123123', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'vendor',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '회사명', label: '회사명' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '2025/12 계',
|
||||
totalValue: 6000000,
|
||||
totalColumnKey: 'amount',
|
||||
footerSummary: [
|
||||
{ label: '지출 합계', value: 6000000 },
|
||||
{ label: '계좌 잔액', value: 10000000 },
|
||||
{ label: '최종 차액', value: 4000000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
export function getMonthlyExpenseModalConfig(_cardId: string): DetailModalConfig | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,76 +2,9 @@ import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 부가세 모달 설정
|
||||
* 모든 카드가 동일한 상세 모달
|
||||
* API 연동 완료 — useVatDetail hook이 실제 데이터 반환
|
||||
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||
*/
|
||||
export function getVatModalConfig(): DetailModalConfig {
|
||||
return {
|
||||
title: '예상 납부세액',
|
||||
periodSelect: {
|
||||
enabled: true,
|
||||
options: [
|
||||
{ value: '2026-1-expected', label: '2026년 1기 예정' },
|
||||
{ value: '2025-2-confirmed', label: '2025년 2기 확정' },
|
||||
{ value: '2025-2-expected', label: '2025년 2기 예정' },
|
||||
{ value: '2025-1-confirmed', label: '2025년 1기 확정' },
|
||||
],
|
||||
defaultValue: '2026-1-expected',
|
||||
},
|
||||
summaryCards: [
|
||||
{ label: '예상매출', value: '30.5억원' },
|
||||
{ label: '예상매입', value: '20.5억원' },
|
||||
{ label: '예상 납부세액', value: '1.1억원' },
|
||||
],
|
||||
// 부가세 요약 테이블
|
||||
referenceTable: {
|
||||
title: '2026년 1기 예정 부가세 요약',
|
||||
columns: [
|
||||
{ key: 'category', label: '구분', align: 'left' },
|
||||
{ key: 'supplyAmount', label: '공급가액', align: 'right' },
|
||||
{ key: 'taxAmount', label: '세액', align: 'right' },
|
||||
],
|
||||
data: [
|
||||
{ category: '매출(전자세금계산서)', supplyAmount: '100,000,000', taxAmount: '10,000,000' },
|
||||
{ category: '매입(전자세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
||||
{ category: '매입(종이세금계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
||||
{ category: '매입(계산서)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
||||
{ category: '매입(신용카드)', supplyAmount: '10,000,000', taxAmount: '1,000,000' },
|
||||
{ category: '납부세액', supplyAmount: '', taxAmount: '6,000,000' },
|
||||
],
|
||||
},
|
||||
// 세금계산서 미발행/미수취 내역
|
||||
table: {
|
||||
title: '세금계산서 미발행/미수취 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'type', label: '구분', align: 'center' },
|
||||
{ key: 'issueDate', label: '발생일자', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
||||
{ key: 'invoiceStatus', label: '세금계산서 미발행/미수취', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '회사명', vat: 11000000, invoiceStatus: '미발행' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '매출', label: '매출' },
|
||||
{ value: '매입', label: '매입' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'vat',
|
||||
},
|
||||
};
|
||||
export function getVatModalConfig(): DetailModalConfig | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,149 +2,9 @@ import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 복리후생비 현황 모달 설정
|
||||
*
|
||||
* @deprecated 정적 목업 데이터 - API 연동 후에는 useWelfareDetail hook 사용 권장
|
||||
*
|
||||
* API 연동 방법:
|
||||
* 1. useWelfareDetail hook 호출하여 modalConfig 가져오기
|
||||
* 2. API 호출 실패 시 이 fallback config 사용
|
||||
*
|
||||
* @example
|
||||
* const { modalConfig, loading, error, refetch } = useWelfareDetail({
|
||||
* calculationType: 'fixed',
|
||||
* year: 2026,
|
||||
* quarter: 1,
|
||||
* });
|
||||
* const config = modalConfig ?? getWelfareModalConfig('fixed'); // fallback
|
||||
*
|
||||
* @param calculationType - 계산 방식 ('fixed': 직원당 정액 금액/월, 'ratio': 연봉 총액 비율)
|
||||
* API 연동 완료 — useWelfareDetail hook이 실제 데이터 반환
|
||||
* 이 함수는 하위 호환용으로 유지하되 null 반환
|
||||
*/
|
||||
export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): DetailModalConfig {
|
||||
// 계산 방식에 따른 조건부 calculationCards 생성
|
||||
const calculationCards = calculationType === 'fixed'
|
||||
? {
|
||||
// 직원당 정액 금액/월 방식
|
||||
title: '복리후생비 계산',
|
||||
subtitle: '직원당 정액 금액/월 200,000원',
|
||||
cards: [
|
||||
{ label: '직원 수', value: 20, unit: '명' },
|
||||
{ label: '연간 직원당 월급 금액', value: 2400000, unit: '원', operator: '×' as const },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 48000000, unit: '원', operator: '=' as const },
|
||||
],
|
||||
}
|
||||
: {
|
||||
// 연봉 총액 비율 방식
|
||||
title: '복리후생비 계산',
|
||||
subtitle: '연봉 총액 기준 비율 20.5%',
|
||||
cards: [
|
||||
{ label: '연봉 총액', value: 1000000000, unit: '원' },
|
||||
{ label: '비율', value: 20.5, unit: '%', operator: '×' as const },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 205000000, unit: '원', operator: '=' as const },
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
title: '복리후생비 상세',
|
||||
dateFilter: {
|
||||
enabled: true,
|
||||
defaultPreset: '당월',
|
||||
showSearch: true,
|
||||
},
|
||||
summaryCards: [
|
||||
// 1행: 당해년도 기준
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 초과 금액', value: 0, unit: '원' },
|
||||
],
|
||||
reviewCards: {
|
||||
title: '복리후생비 검토 필요',
|
||||
cards: [
|
||||
{ label: '비과세 한도 초과', amount: 3123000, subLabel: '5건' },
|
||||
{ label: '사적 사용 의심', amount: 3123000, subLabel: '5건' },
|
||||
{ label: '특정인 편중', amount: 3123000, subLabel: '5건' },
|
||||
{ label: '항목별 한도 초과', amount: 3123000, subLabel: '5건' },
|
||||
],
|
||||
},
|
||||
barChart: {
|
||||
title: '월별 복리후생비 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 1500000 },
|
||||
{ name: '2월', value: 1800000 },
|
||||
{ name: '3월', value: 2200000 },
|
||||
{ name: '4월', value: 1900000 },
|
||||
{ name: '5월', value: 2100000 },
|
||||
{ name: '6월', value: 1700000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '항목별 사용 비율',
|
||||
data: [
|
||||
{ name: '식비', value: 55000000, percentage: 55, color: '#FBBF24' },
|
||||
{ name: '건강검진', value: 25000000, percentage: 5, color: '#60A5FA' },
|
||||
{ name: '경조사비', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
{ name: '기타', value: 10000000, percentage: 30, color: '#34D399' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 복리후생비 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, content: '비과세 한도 초과' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, content: '사적 사용 의심' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, content: '특정인 편중' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, content: '항목별 한도 초과' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, content: '비과세 한도 초과' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '비과세 한도 초과', label: '비과세 한도 초과' },
|
||||
{ value: '사적 사용 의심', label: '사적 사용 의심' },
|
||||
{ value: '특정인 편중', label: '특정인 편중' },
|
||||
{ value: '항목별 한도 초과', label: '항목별 한도 초과' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
// 복리후생비 계산 (조건부 - calculationType에 따라)
|
||||
calculationCards,
|
||||
// 복리후생비 현황 (분기별 테이블)
|
||||
quarterlyTable: {
|
||||
title: '복리후생비 현황',
|
||||
rows: [
|
||||
{ label: '한도금액', q1: 12000000, q2: 12000000, q3: 12000000, q4: 12000000, total: 48000000 },
|
||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
export function getWelfareModalConfig(_calculationType: 'fixed' | 'ratio'): DetailModalConfig | null {
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift } from 'lucide-react';
|
||||
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift, CheckCircle2, ShieldAlert } from 'lucide-react';
|
||||
import { formatKoreanAmount } from '@/lib/utils/amount';
|
||||
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
|
||||
import type { CardManagementData } from '../types';
|
||||
|
||||
@@ -14,9 +16,33 @@ interface CardManagementSectionProps {
|
||||
onCardClick?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
/** subLabel에서 "미정리 N건", "미증빙 N건" 등의 건수를 파싱 */
|
||||
function parseIssueCount(subLabel?: string): number {
|
||||
if (!subLabel) return 0;
|
||||
const match = subLabel.match(/(\d+)\s*건/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 카드별 미정리/미증빙 건수 집계
|
||||
const issueStats = useMemo(() => {
|
||||
let totalCount = 0;
|
||||
let totalAmount = 0;
|
||||
const issueCards: string[] = [];
|
||||
|
||||
for (const card of data.cards) {
|
||||
const count = parseIssueCount(card.subLabel);
|
||||
if (count > 0 || card.isHighlighted) {
|
||||
totalCount += count;
|
||||
totalAmount += card.subAmount ?? 0;
|
||||
issueCards.push(card.label);
|
||||
}
|
||||
}
|
||||
return { totalCount, totalAmount, issueCards, hasIssues: totalCount > 0 };
|
||||
}, [data.cards]);
|
||||
|
||||
const handleClick = (cardId: string) => {
|
||||
if (onCardClick) {
|
||||
onCardClick(cardId);
|
||||
@@ -31,9 +57,46 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
||||
title="가지급금 현황"
|
||||
subtitle="가지급금 관리 현황"
|
||||
>
|
||||
{data.warningBanner && (
|
||||
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{/* 상태 배너: 미정리 있으면 빨간 펄스, 정상이면 초록 */}
|
||||
{issueStats.hasIssues ? (
|
||||
<div className="relative overflow-hidden rounded-lg mb-4">
|
||||
{/* 펄스 배경 */}
|
||||
<div className="absolute inset-0 bg-red-500 animate-pulse opacity-20 rounded-lg" />
|
||||
<div className="relative bg-red-50 dark:bg-red-900/40 border border-red-300 dark:border-red-700 px-4 py-3 rounded-lg flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center shrink-0">
|
||||
<ShieldAlert className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-300">
|
||||
미정리 {issueStats.totalCount}건
|
||||
</p>
|
||||
<p className="text-xs text-red-600/80 dark:text-red-400/80">
|
||||
{issueStats.issueCards.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{issueStats.totalAmount > 0 && (
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-lg font-bold text-red-700 dark:text-red-300">
|
||||
{formatKoreanAmount(issueStats.totalAmount)}
|
||||
</p>
|
||||
<p className="text-[11px] text-red-500/70 dark:text-red-400/60">미정리 총액</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-2.5 rounded-lg mb-4 flex items-center gap-2.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">미정리 건 없음 — 가지급금 정상 관리 중</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 warningBanner 호환 (issueStats과 별도 메시지가 있는 경우) */}
|
||||
{data.warningBanner && issueStats.hasIssues && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium px-3 py-2 rounded-lg mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{data.warningBanner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/ko/hr/attendance'); }}
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/hr/attendance-management'); }}
|
||||
className="text-white hover:bg-white/10 gap-1 text-xs"
|
||||
>
|
||||
근태관리
|
||||
|
||||
@@ -83,6 +83,12 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
||||
}
|
||||
>
|
||||
{/* 공정 탭 */}
|
||||
{data.processes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Factory className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">오늘 등록된 작업 지시가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-4">
|
||||
{data.processes.map((process) => (
|
||||
@@ -240,6 +246,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
</CollapsibleDashboardCard>
|
||||
|
||||
|
||||
293
src/components/business/CEODashboard/useSectionSummary.ts
Normal file
293
src/components/business/CEODashboard/useSectionSummary.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useEffect, useState, useRef, useCallback } from 'react';
|
||||
import type { CEODashboardData, DashboardSettings, SectionKey } from './types';
|
||||
import { SECTION_LABELS } from './types';
|
||||
|
||||
export type SummaryStatus = 'normal' | 'warning' | 'danger';
|
||||
|
||||
export interface SectionSummary {
|
||||
key: SectionKey;
|
||||
label: string;
|
||||
value: string;
|
||||
status: SummaryStatus;
|
||||
}
|
||||
|
||||
/** 숫자를 간략하게 포맷 (억/만) — 칩 표시용 (간결 + 반올림) */
|
||||
function formatCompact(n: number): string {
|
||||
if (n === 0) return '0원';
|
||||
const abs = Math.abs(n);
|
||||
const sign = n < 0 ? '-' : '';
|
||||
if (abs >= 100_000_000) {
|
||||
const v = Math.round(abs / 100_000_000 * 10) / 10;
|
||||
return `${sign}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}억`;
|
||||
}
|
||||
if (abs >= 10_000) {
|
||||
const v = Math.round(abs / 10_000);
|
||||
return `${sign}${v.toLocaleString()}만`;
|
||||
}
|
||||
if (abs > 0) return `${sign}${abs.toLocaleString()}원`;
|
||||
return '0원';
|
||||
}
|
||||
|
||||
/** 카드 배열에서 합계 카드(마지막) 금액 추출 — "합계" 라벨이 있으면 그것, 없으면 첫 번째 */
|
||||
function getTotalCardAmount(cards?: { label: string; amount: number }[]): number {
|
||||
if (!cards?.length) return 0;
|
||||
const totalCard = cards.find((c) => c.label.includes('합계'));
|
||||
return totalCard ? totalCard.amount : cards[0].amount;
|
||||
}
|
||||
|
||||
/** 체크포인트 배열에서 가장 심각한 상태 추출 */
|
||||
function checkPointStatus(
|
||||
checkPoints?: { type: string }[],
|
||||
): SummaryStatus {
|
||||
if (!checkPoints?.length) return 'normal';
|
||||
if (checkPoints.some((c) => c.type === 'error')) return 'danger';
|
||||
if (checkPoints.some((c) => c.type === 'warning')) return 'warning';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/** 섹션 활성화 여부 확인 */
|
||||
function isSectionEnabled(key: SectionKey, settings: DashboardSettings): boolean {
|
||||
switch (key) {
|
||||
case 'todayIssueList': return !!settings.todayIssueList;
|
||||
case 'dailyReport': return !!settings.dailyReport;
|
||||
case 'statusBoard': return !!(settings.statusBoard?.enabled ?? settings.todayIssue.enabled);
|
||||
case 'monthlyExpense': return !!settings.monthlyExpense;
|
||||
case 'cardManagement': return !!settings.cardManagement;
|
||||
case 'entertainment': return !!settings.entertainment.enabled;
|
||||
case 'welfare': return !!settings.welfare.enabled;
|
||||
case 'receivable': return !!settings.receivable;
|
||||
case 'debtCollection': return !!settings.debtCollection;
|
||||
case 'vat': return !!settings.vat;
|
||||
case 'calendar': return !!settings.calendar;
|
||||
case 'salesStatus': return !!(settings.salesStatus ?? true);
|
||||
case 'purchaseStatus': return !!(settings.purchaseStatus ?? true);
|
||||
case 'production': return !!(settings.production ?? true);
|
||||
case 'shipment': return !!(settings.shipment ?? true);
|
||||
case 'unshipped': return !!(settings.unshipped ?? true);
|
||||
case 'construction': return !!(settings.construction ?? true);
|
||||
case 'attendance': return !!(settings.attendance ?? true);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 섹션별 요약값 + 상태 추출 */
|
||||
function extractSummary(
|
||||
key: SectionKey,
|
||||
data: CEODashboardData,
|
||||
): { value: string; status: SummaryStatus } {
|
||||
switch (key) {
|
||||
case 'todayIssueList': {
|
||||
const count = data.todayIssueList?.length ?? 0;
|
||||
return { value: `${count}건`, status: count > 0 ? 'warning' : 'normal' };
|
||||
}
|
||||
case 'dailyReport': {
|
||||
const firstCard = data.dailyReport?.cards?.[0];
|
||||
return {
|
||||
value: firstCard ? formatCompact(firstCard.amount) : '-',
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
case 'statusBoard': {
|
||||
const count = data.todayIssue?.length ?? 0;
|
||||
const hasHighlight = data.todayIssue?.some((i) => i.isHighlighted);
|
||||
return {
|
||||
value: `${count}항목`,
|
||||
status: hasHighlight ? 'danger' : 'normal',
|
||||
};
|
||||
}
|
||||
case 'monthlyExpense': {
|
||||
const total = getTotalCardAmount(data.monthlyExpense?.cards);
|
||||
return {
|
||||
value: formatCompact(total),
|
||||
status: checkPointStatus(data.monthlyExpense?.checkPoints),
|
||||
};
|
||||
}
|
||||
case 'cardManagement': {
|
||||
const total = getTotalCardAmount(data.cardManagement?.cards);
|
||||
const hasHighlight = data.cardManagement?.cards?.some((c) => c.isHighlighted);
|
||||
const hasWarning = !!data.cardManagement?.warningBanner;
|
||||
return {
|
||||
value: formatCompact(total),
|
||||
status: hasHighlight ? 'danger' : hasWarning ? 'warning' : 'normal',
|
||||
};
|
||||
}
|
||||
case 'entertainment': {
|
||||
const total = data.entertainment?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0;
|
||||
return {
|
||||
value: formatCompact(total),
|
||||
status: checkPointStatus(data.entertainment?.checkPoints),
|
||||
};
|
||||
}
|
||||
case 'welfare': {
|
||||
const total = data.welfare?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0;
|
||||
return {
|
||||
value: formatCompact(total),
|
||||
status: checkPointStatus(data.welfare?.checkPoints),
|
||||
};
|
||||
}
|
||||
case 'receivable': {
|
||||
// 누적 미수금 = 첫 번째 카드
|
||||
const first = data.receivable?.cards?.[0];
|
||||
return {
|
||||
value: first ? formatCompact(first.amount) : '-',
|
||||
status: checkPointStatus(data.receivable?.checkPoints),
|
||||
};
|
||||
}
|
||||
case 'debtCollection': {
|
||||
const first = data.debtCollection?.cards?.[0];
|
||||
return {
|
||||
value: first ? formatCompact(first.amount) : '-',
|
||||
status: checkPointStatus(data.debtCollection?.checkPoints),
|
||||
};
|
||||
}
|
||||
case 'vat': {
|
||||
const first = data.vat?.cards?.[0];
|
||||
return {
|
||||
value: first ? formatCompact(first.amount) : '-',
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
case 'calendar': {
|
||||
const count = data.calendarSchedules?.length ?? 0;
|
||||
return { value: `${count}일정`, status: 'normal' };
|
||||
}
|
||||
case 'salesStatus': {
|
||||
return {
|
||||
value: formatCompact(data.salesStatus?.cumulativeSales ?? 0),
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
case 'purchaseStatus': {
|
||||
return {
|
||||
value: formatCompact(data.purchaseStatus?.cumulativePurchase ?? 0),
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
case 'production': {
|
||||
const count = data.dailyProduction?.processes?.length ?? 0;
|
||||
return { value: `${count}공정`, status: 'normal' };
|
||||
}
|
||||
case 'shipment': {
|
||||
const count = data.dailyProduction?.shipment?.actualCount ?? 0;
|
||||
return { value: `${count}건`, status: 'normal' };
|
||||
}
|
||||
case 'unshipped': {
|
||||
const count = data.unshipped?.items?.length ?? 0;
|
||||
return {
|
||||
value: `${count}건`,
|
||||
status: count > 0 ? 'danger' : 'normal',
|
||||
};
|
||||
}
|
||||
case 'construction': {
|
||||
return {
|
||||
value: `${data.constructionData?.thisMonth ?? 0}건`,
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
case 'attendance': {
|
||||
return {
|
||||
value: `${data.dailyAttendance?.present ?? 0}명`,
|
||||
status: 'normal',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { value: '-', status: 'normal' };
|
||||
}
|
||||
}
|
||||
|
||||
interface UseSectionSummaryParams {
|
||||
data: CEODashboardData;
|
||||
sectionOrder: SectionKey[];
|
||||
dashboardSettings: DashboardSettings;
|
||||
}
|
||||
|
||||
interface UseSectionSummaryReturn {
|
||||
summaries: SectionSummary[];
|
||||
activeSectionKey: SectionKey | null;
|
||||
sectionRefs: React.MutableRefObject<Map<SectionKey, HTMLElement>>;
|
||||
scrollToSection: (key: SectionKey) => void;
|
||||
}
|
||||
|
||||
export function useSectionSummary({
|
||||
data,
|
||||
sectionOrder,
|
||||
dashboardSettings,
|
||||
}: UseSectionSummaryParams): UseSectionSummaryReturn {
|
||||
const sectionRefs = useRef<Map<SectionKey, HTMLElement>>(new Map());
|
||||
const [activeSectionKey, setActiveSectionKey] = useState<SectionKey | null>(null);
|
||||
// 칩 클릭으로 선택된 키 — 해당 섹션이 화면에 보이는 한 유지
|
||||
const pinnedKey = useRef<SectionKey | null>(null);
|
||||
|
||||
// 활성화된 섹션만 필터
|
||||
const enabledSections = useMemo(
|
||||
() => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)),
|
||||
[sectionOrder, dashboardSettings],
|
||||
);
|
||||
|
||||
// 요약 데이터 계산
|
||||
const summaries = useMemo<SectionSummary[]>(
|
||||
() =>
|
||||
enabledSections.map((key) => {
|
||||
const { value, status } = extractSummary(key, data);
|
||||
return { key, label: SECTION_LABELS[key], value, status };
|
||||
}),
|
||||
[enabledSections, data],
|
||||
);
|
||||
|
||||
// 스크롤 기반 현재 섹션 감지
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// pin이 걸려 있으면 스크롤 감지 무시 (칩 클릭 후 programmatic scroll 중)
|
||||
if (pinnedKey.current) return;
|
||||
|
||||
const headerBottom = 156; // 헤더(~100px) + 요약바(~56px)
|
||||
let bestKey: SectionKey | null = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const [key, el] of sectionRefs.current.entries()) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const distance = Math.abs(rect.top - headerBottom);
|
||||
if (rect.top < window.innerHeight * 0.6 && rect.bottom > headerBottom) {
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestKey) {
|
||||
setActiveSectionKey(bestKey);
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자가 직접 스크롤(마우스 휠/터치)하면 pin 해제
|
||||
const handleUserScroll = () => { pinnedKey.current = null; };
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('wheel', handleUserScroll, { passive: true });
|
||||
window.addEventListener('touchstart', handleUserScroll, { passive: true });
|
||||
handleScroll(); // 초기 호출
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('wheel', handleUserScroll);
|
||||
window.removeEventListener('touchstart', handleUserScroll);
|
||||
};
|
||||
}, [enabledSections, summaries]);
|
||||
|
||||
// 칩 클릭 → 즉시 활성 표시 + 섹션으로 스크롤
|
||||
const scrollToSection = useCallback((key: SectionKey) => {
|
||||
setActiveSectionKey(key);
|
||||
pinnedKey.current = key; // 해당 섹션이 화면에 보이는 한 유지
|
||||
|
||||
const el = sectionRefs.current.get(key);
|
||||
if (!el) return;
|
||||
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const offset = window.scrollY + elRect.top - 160; // 헤더(~100) + 요약바(~56) + 여유
|
||||
|
||||
window.scrollTo({ top: offset, behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
return { summaries, activeSectionKey, sectionRefs, scrollToSection };
|
||||
}
|
||||
@@ -198,7 +198,8 @@ export function DocumentViewer({
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 원본 src 유지
|
||||
// 변환 실패 시 빈 이미지로 대체 (Puppeteer에서 proxy URL 요청 방지)
|
||||
clonedImg.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Star } from 'lucide-react';
|
||||
import { Bookmark, MoreHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -20,14 +20,68 @@ import { useFavoritesStore } from '@/stores/favoritesStore';
|
||||
import { iconMap } from '@/lib/utils/menuTransform';
|
||||
import type { FavoriteItem } from '@/stores/favoritesStore';
|
||||
|
||||
// "시스템 대시보드" 기준 텍스트 폭 (7글자 ≈ 80px)
|
||||
const TEXT_DEFAULT_MAX = 80;
|
||||
const TEXT_EXPANDED_MAX = 200;
|
||||
const TEXT_SHRUNK_MAX = 28;
|
||||
const OVERFLOW_BTN_WIDTH = 56;
|
||||
const GAP = 6;
|
||||
|
||||
interface HeaderFavoritesBarProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
/** 별 아이콘 드롭다운 (공간 부족 / 모바일 / 태블릿) */
|
||||
function StarDropdown({
|
||||
favorites,
|
||||
className,
|
||||
onItemClick,
|
||||
}: {
|
||||
favorites: FavoriteItem[];
|
||||
className?: string;
|
||||
onItemClick: (item: FavoriteItem) => void;
|
||||
}) {
|
||||
const getIcon = (name: string) => iconMap[name] || null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Bookmark className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
|
||||
const router = useRouter();
|
||||
const { favorites } = useFavoritesStore();
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chipWidthsRef = useRef<number[]>([]);
|
||||
const measuredRef = useRef(false);
|
||||
const [visibleCount, setVisibleCount] = useState(favorites.length);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
// 태블릿 감지 (768~1024)
|
||||
useEffect(() => {
|
||||
@@ -40,6 +94,70 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
// 즐겨찾기 변경 시 측정 리셋
|
||||
useEffect(() => {
|
||||
measuredRef.current = false;
|
||||
chipWidthsRef.current = [];
|
||||
setVisibleCount(favorites.length);
|
||||
}, [favorites.length]);
|
||||
|
||||
// 모바일/태블릿 ↔ 데스크탑 전환 시 측정 리셋
|
||||
useEffect(() => {
|
||||
if (!isMobile && !isTablet) {
|
||||
measuredRef.current = false;
|
||||
chipWidthsRef.current = [];
|
||||
setVisibleCount(favorites.length);
|
||||
}
|
||||
}, [isMobile, isTablet, favorites.length]);
|
||||
|
||||
// 데스크탑 동적 오버플로: 전체 chip 폭 측정 → 저장 → resize 시 재계산
|
||||
useEffect(() => {
|
||||
if (isMobile || isTablet) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const calculate = () => {
|
||||
// 최초: 전체 chip 렌더 상태에서 폭 저장
|
||||
if (!measuredRef.current) {
|
||||
const chips = container.querySelectorAll<HTMLElement>('[data-chip]');
|
||||
if (chips.length === favorites.length && chips.length > 0) {
|
||||
chipWidthsRef.current = Array.from(chips).map((c) => c.offsetWidth);
|
||||
measuredRef.current = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
const widths = chipWidthsRef.current;
|
||||
|
||||
// 공간 부족: chip 1개 + overflow 버튼도 안 들어가면 전부 드롭다운
|
||||
const minChipWidth = Math.min(...widths);
|
||||
if (containerWidth < minChipWidth + OVERFLOW_BTN_WIDTH + GAP) {
|
||||
setVisibleCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < widths.length; i++) {
|
||||
const needed = totalWidth + widths[i] + (count > 0 ? GAP : 0);
|
||||
const hasMore = i < widths.length - 1;
|
||||
const reserve = hasMore ? OVERFLOW_BTN_WIDTH + GAP : 0;
|
||||
if (needed + reserve > containerWidth && count > 0) break;
|
||||
totalWidth = needed;
|
||||
count++;
|
||||
}
|
||||
setVisibleCount(count);
|
||||
};
|
||||
|
||||
requestAnimationFrame(calculate);
|
||||
const observer = new ResizeObserver(calculate);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile, isTablet, favorites.length]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: FavoriteItem) => {
|
||||
router.push(item.path);
|
||||
@@ -49,106 +167,121 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
|
||||
if (favorites.length === 0) return null;
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
return iconMap[iconName] || null;
|
||||
};
|
||||
const getIcon = (iconName: string) => iconMap[iconName] || null;
|
||||
|
||||
// 모바일 & 태블릿: 별 아이콘 드롭다운
|
||||
if (isMobile || isTablet) {
|
||||
// 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${
|
||||
isMobile
|
||||
? 'min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px]'
|
||||
: 'w-10 h-10'
|
||||
}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<StarDropdown
|
||||
favorites={favorites}
|
||||
onItemClick={handleClick}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] rounded-lg min-[320px]:rounded-xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운
|
||||
const DESKTOP_ICON_LIMIT = 8;
|
||||
|
||||
if (favorites.length > DESKTOP_ICON_LIMIT) {
|
||||
// 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지)
|
||||
if (isTablet) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-10 h-10 p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1 min-w-0 flex items-center justify-end">
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} className="w-10 h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성)
|
||||
const visibleItems = favorites.slice(0, visibleCount);
|
||||
const overflowItems = favorites.slice(visibleCount);
|
||||
const showStarOnly = measuredRef.current && visibleCount === 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-2">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 p-0 flex items-center justify-center transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-w-0 flex items-center justify-end gap-1.5"
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{showStarOnly ? (
|
||||
<StarDropdown favorites={favorites} onItemClick={handleClick} />
|
||||
) : (
|
||||
<>
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
const isHovered = hoveredId === item.id;
|
||||
const isOtherHovered = hoveredId !== null && !isHovered;
|
||||
|
||||
const textMaxWidth = isHovered
|
||||
? TEXT_EXPANDED_MAX
|
||||
: isOtherHovered
|
||||
? TEXT_SHRUNK_MAX
|
||||
: TEXT_DEFAULT_MAX;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-chip
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
className={`rounded-full text-white h-8 flex items-center overflow-hidden ${
|
||||
isOtherHovered ? 'px-2 gap-1 bg-blue-400/70' : 'px-3 gap-1.5 bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
transition: 'all 500ms cubic-bezier(0.25, 0.8, 0.25, 1)',
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span
|
||||
className="text-xs whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{
|
||||
maxWidth: textMaxWidth,
|
||||
transition: 'max-width 500ms cubic-bezier(0.25, 0.8, 0.25, 1), opacity 400ms ease',
|
||||
opacity: isOtherHovered ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="rounded-full bg-blue-500/80 hover:bg-blue-600 text-white h-8 px-2.5 gap-1 flex items-center shrink-0"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">+{overflowItems.length}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflowItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
|
||||
import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
|
||||
import type { MenuItem } from '@/stores/menuStore';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore';
|
||||
@@ -159,7 +159,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,7 +224,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@ function MenuItemComponent({
|
||||
}`}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
type InspectionTemplateResponse,
|
||||
type DocumentResolveResponse,
|
||||
} from './actions';
|
||||
import { captureRenderedHtml } from '@/lib/utils/capture-rendered-html';
|
||||
import { ImportInspectionDocument } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
|
||||
import type { ImportInspectionTemplate, InspectionItemValue } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
|
||||
|
||||
// ===== Props =====
|
||||
interface ImportInspectionInputModalProps {
|
||||
@@ -636,7 +639,37 @@ export function ImportInspectionInputModal({
|
||||
})),
|
||||
];
|
||||
|
||||
// 4. 저장 API 호출
|
||||
// 4. 성적서 문서를 오프스크린 렌더링하여 HTML 스냅샷 캡처 (MNG 출력용)
|
||||
let renderedHtml: string | undefined;
|
||||
try {
|
||||
// 현재 입력값을 ImportInspectionDocument의 initialValues 형식으로 변환
|
||||
const docValues: InspectionItemValue[] = template.inspectionItems
|
||||
.filter(i => i.isFirstInItem !== false)
|
||||
.map(item => ({
|
||||
itemId: item.id,
|
||||
measurements: Array.from({ length: item.measurementCount }, (_, n) => {
|
||||
if (item.measurementType === 'okng') {
|
||||
const v = okngValues[item.id]?.[n];
|
||||
return v === 'ok' ? ('OK' as const) : v === 'ng' ? ('NG' as const) : null;
|
||||
}
|
||||
const v = measurements[item.id]?.[n];
|
||||
return v ? Number(v) : null;
|
||||
}),
|
||||
result: getItemResult(item) === 'ok' ? ('OK' as const) : getItemResult(item) === 'ng' ? ('NG' as const) : null,
|
||||
}));
|
||||
// 성적서 문서 컴포넌트를 오프스크린에서 렌더링
|
||||
renderedHtml = captureRenderedHtml(
|
||||
<ImportInspectionDocument
|
||||
template={template as unknown as ImportInspectionTemplate}
|
||||
initialValues={docValues}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// 캡처 실패 시 무시 — rendered_html 없이 저장 진행
|
||||
}
|
||||
|
||||
// 5. 저장 API 호출
|
||||
const result = await saveInspectionData({
|
||||
templateId: parseInt(template.templateId),
|
||||
itemId,
|
||||
@@ -645,6 +678,7 @@ export function ImportInspectionInputModal({
|
||||
attachments,
|
||||
receivingId,
|
||||
inspectionResult: overallResult,
|
||||
rendered_html: renderedHtml,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { materialInspectionCreateConfig } from './inspectionConfig';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
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';
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { getReceivings } from './actions';
|
||||
import type { InspectionCheckItem, ReceivingItem } from './types';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
@@ -81,7 +80,7 @@ export function InspectionCreate({ id }: Props) {
|
||||
const [opinion, setOpinion] = useState('');
|
||||
|
||||
// 유효성 검사 에러
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 성공 다이얼로그
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
@@ -117,15 +116,22 @@ export function InspectionCreate({ id }: Props) {
|
||||
// 대상 선택 핸들러
|
||||
const handleTargetSelect = useCallback((targetId: string) => {
|
||||
setSelectedTargetId(targetId);
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 판정 변경 핸들러
|
||||
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
|
||||
const handleJudgmentChange = useCallback((itemId: string, index: number, judgment: '적' | '부적') => {
|
||||
setInspectionItems((prev) =>
|
||||
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
||||
);
|
||||
setValidationErrors([]);
|
||||
// 해당 항목의 에러 클리어
|
||||
setValidationErrors((prev) => {
|
||||
const key = `judgment_${index}`;
|
||||
if (prev[key]) {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 비고 변경 핸들러
|
||||
@@ -137,22 +143,29 @@ export function InspectionCreate({ id }: Props) {
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// 필수 필드: 검사자
|
||||
if (!inspector.trim()) {
|
||||
errors.push('검사자는 필수 입력 항목입니다.');
|
||||
errors.inspector = '검사자는 필수 입력 항목입니다.';
|
||||
}
|
||||
|
||||
// 검사 항목 판정 확인
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (!item.judgment) {
|
||||
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
|
||||
errors[`judgment_${index}`] = `${item.name}: 판정을 선택해주세요.`;
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [inspector, inspectionItems]);
|
||||
|
||||
// 검사 저장
|
||||
@@ -214,30 +227,6 @@ export function InspectionCreate({ id }: Props) {
|
||||
|
||||
{/* 우측: 검사 정보 및 항목 */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{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">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||
<h3 className="font-medium">검사 정보</h3>
|
||||
@@ -257,10 +246,19 @@ export function InspectionCreate({ id }: Props) {
|
||||
value={inspector}
|
||||
onChange={(e) => {
|
||||
setInspector(e.target.value);
|
||||
setValidationErrors([]);
|
||||
if (validationErrors.inspector) {
|
||||
setValidationErrors((prev) => {
|
||||
const { inspector: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="검사자명 입력"
|
||||
className={validationErrors.inspector ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.inspector && (
|
||||
<p className="text-sm text-red-500">{validationErrors.inspector}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
|
||||
@@ -284,39 +282,45 @@ export function InspectionCreate({ id }: Props) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspectionItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{item.specification}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={item.judgment || ''}
|
||||
onValueChange={(value) =>
|
||||
handleJudgmentChange(item.id, value as '적' | '부적')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="적">적</SelectItem>
|
||||
<SelectItem value="부적">부적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
value={item.remark || ''}
|
||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{inspectionItems.map((item, index) => {
|
||||
const judgmentErrorKey = `judgment_${index}`;
|
||||
return (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{item.specification}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={item.judgment || ''}
|
||||
onValueChange={(value) =>
|
||||
handleJudgmentChange(item.id, index, value as '적' | '부적')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={`h-8 ${validationErrors[judgmentErrorKey] ? 'border-red-500' : ''}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="적">적</SelectItem>
|
||||
<SelectItem value="부적">부적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors[judgmentErrorKey] && (
|
||||
<p className="text-xs text-red-500 mt-1">{validationErrors[judgmentErrorKey]}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
value={item.remark || ''}
|
||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -361,4 +365,4 @@ export function InspectionCreate({ id }: Props) {
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1874,6 +1874,7 @@ export async function saveInspectionData(params: {
|
||||
attachments?: Array<{ file_id: number; attachment_type: string; description?: string }>;
|
||||
receivingId: string;
|
||||
inspectionResult?: 'pass' | 'fail' | null;
|
||||
rendered_html?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -1889,6 +1890,7 @@ export async function saveInspectionData(params: {
|
||||
title: params.title || '수입검사 성적서',
|
||||
data: params.data,
|
||||
attachments: params.attachments || [],
|
||||
rendered_html: params.rendered_html,
|
||||
},
|
||||
errorMessage: '검사 데이터 저장에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ export function OrderRegistration({
|
||||
|
||||
// 컴포넌트 마운트 시 거래처 목록 불러오기
|
||||
useEffect(() => {
|
||||
fetchClients({ onlyActive: true, size: 100 });
|
||||
fetchClients({ onlyActive: true, size: 1000 });
|
||||
}, [fetchClients]);
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type OrderStatus,
|
||||
} from "@/components/orders";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
import { invalidateDashboard } from "@/lib/dashboard-invalidation";
|
||||
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
@@ -293,6 +294,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "cancelled");
|
||||
if (result.success) {
|
||||
invalidateDashboard('sales');
|
||||
setOrder({ ...order, status: "cancelled" });
|
||||
toast.success("수주가 취소되었습니다.");
|
||||
setIsCancelDialogOpen(false);
|
||||
@@ -321,6 +323,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "order_confirmed");
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('sales');
|
||||
setOrder(result.data);
|
||||
toast.success("수주가 확정되었습니다.");
|
||||
setIsConfirmDialogOpen(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, cloneElement, isValidElement } from 'react';
|
||||
import { Search, X, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -38,6 +38,7 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
|
||||
listWrapper,
|
||||
infoText,
|
||||
mode,
|
||||
isItemDisabled,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
@@ -88,15 +89,20 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체선택 토글
|
||||
// 전체선택 토글 (비활성 아이템 제외)
|
||||
const handleToggleAll = useCallback(() => {
|
||||
const targetItems = isItemDisabled
|
||||
? items.filter((item) => !isItemDisabled(item, items.filter((i) => selectedIds.has(keyExtractor(i)))))
|
||||
: items;
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === items.length) {
|
||||
const targetIds = targetItems.map((item) => keyExtractor(item));
|
||||
const allSelected = targetIds.every((id) => prev.has(id));
|
||||
if (allSelected) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(items.map((item) => keyExtractor(item)));
|
||||
return new Set(targetIds);
|
||||
});
|
||||
}, [items, keyExtractor]);
|
||||
}, [items, keyExtractor, isItemDisabled, selectedIds]);
|
||||
|
||||
// 다중선택 확인
|
||||
const handleConfirm = useCallback(() => {
|
||||
@@ -107,16 +113,34 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
|
||||
}
|
||||
}, [mode, items, selectedIds, keyExtractor, props, onOpenChange]);
|
||||
|
||||
// 선택된 아이템 목록 (isItemDisabled 콜백용)
|
||||
const selectedItems = useCallback(() => {
|
||||
return items.filter((item) => selectedIds.has(keyExtractor(item)));
|
||||
}, [items, selectedIds, keyExtractor]);
|
||||
|
||||
// 비활성 판정
|
||||
const checkDisabled = useCallback((item: T) => {
|
||||
if (!isItemDisabled) return false;
|
||||
// 이미 선택된 아이템은 disabled가 아님 (해제 가능해야 함)
|
||||
if (selectedIds.has(keyExtractor(item))) return false;
|
||||
return isItemDisabled(item, selectedItems());
|
||||
}, [isItemDisabled, selectedIds, keyExtractor, selectedItems]);
|
||||
|
||||
// 클릭 핸들러: 모드에 따라 분기
|
||||
const handleItemClick = useCallback((item: T) => {
|
||||
if (checkDisabled(item)) return;
|
||||
if (mode === 'single') {
|
||||
handleSingleSelect(item);
|
||||
} else {
|
||||
handleToggle(keyExtractor(item));
|
||||
}
|
||||
}, [mode, handleSingleSelect, handleToggle, keyExtractor]);
|
||||
}, [mode, handleSingleSelect, handleToggle, keyExtractor, checkDisabled]);
|
||||
|
||||
const isAllSelected = items.length > 0 && selectedIds.size === items.length;
|
||||
// 전체선택 (비활성 아이템 제외)
|
||||
const enabledItems = isItemDisabled
|
||||
? items.filter((item) => !checkDisabled(item))
|
||||
: items;
|
||||
const isAllSelected = enabledItems.length > 0 && enabledItems.every((item) => selectedIds.has(keyExtractor(item)));
|
||||
const isSelected = (item: T) => selectedIds.has(keyExtractor(item));
|
||||
|
||||
// 빈 상태 메시지 결정
|
||||
@@ -156,11 +180,42 @@ export function SearchableSelectionModal<T>(props: SearchableSelectionModalProps
|
||||
);
|
||||
}
|
||||
|
||||
const itemElements = items.map((item) => (
|
||||
<div key={keyExtractor(item)} onClick={() => handleItemClick(item)} className="cursor-pointer">
|
||||
{renderItem(item, isSelected(item))}
|
||||
</div>
|
||||
));
|
||||
const itemElements = items.map((item) => {
|
||||
const key = keyExtractor(item);
|
||||
const disabled = checkDisabled(item);
|
||||
const rendered = renderItem(item, isSelected(item), disabled);
|
||||
|
||||
// renderItem이 유효한 React 엘리먼트를 반환하면 key와 onClick을 직접 주입 (div 래핑 없이)
|
||||
// 이렇게 하면 <TableRow> 등 테이블 요소를 <div>로 감싸는 HTML 유효성 에러를 방지
|
||||
if (isValidElement(rendered)) {
|
||||
return cloneElement(rendered as React.ReactElement<Record<string, unknown>>, {
|
||||
key,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if (disabled) return;
|
||||
const existingOnClick = (rendered.props as Record<string, unknown>)?.onClick;
|
||||
if (typeof existingOnClick === 'function') {
|
||||
(existingOnClick as (e: React.MouseEvent) => void)(e);
|
||||
}
|
||||
handleItemClick(item);
|
||||
},
|
||||
className: [
|
||||
disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer',
|
||||
(rendered.props as Record<string, unknown>)?.className || '',
|
||||
].filter(Boolean).join(' '),
|
||||
});
|
||||
}
|
||||
|
||||
// 일반 텍스트/fragment인 경우 기존 div 래핑 유지
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={disabled ? undefined : () => handleItemClick(item)}
|
||||
className={disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (listWrapper) {
|
||||
const selectState = mode === 'multiple'
|
||||
|
||||
@@ -17,8 +17,10 @@ interface BaseProps<T> {
|
||||
fetchData: (query: string) => Promise<T[]>;
|
||||
/** 고유 키 추출 */
|
||||
keyExtractor: (item: T) => string;
|
||||
/** 아이템 렌더링 */
|
||||
renderItem: (item: T, isSelected: boolean) => ReactNode;
|
||||
/** 아이템 렌더링 (isDisabled: 비활성 상태) */
|
||||
renderItem: (item: T, isSelected: boolean, isDisabled?: boolean) => ReactNode;
|
||||
/** 아이템 비활성 조건 (선택된 아이템 목록 기반) */
|
||||
isItemDisabled?: (item: T, selectedItems: T[]) => boolean;
|
||||
|
||||
// 검색 설정
|
||||
/** 검색 모드: debounce(자동) vs enter(수동) */
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -138,7 +137,7 @@ export function ShipmentCreate() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
@@ -226,7 +225,9 @@ export function ShipmentCreate() {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors.lotNo) {
|
||||
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
@@ -245,7 +246,13 @@ export function ShipmentCreate() {
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 배차 정보 핸들러
|
||||
@@ -289,12 +296,16 @@ export function ShipmentCreate() {
|
||||
}, [router]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
if (!formData.lotNo) errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
const errors: Record<string, string> = {};
|
||||
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
|
||||
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
|
||||
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -349,30 +360,6 @@ export function ShipmentCreate() {
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{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">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -393,7 +380,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="로트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,6 +391,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
|
||||
</div>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
@@ -432,7 +420,9 @@ export function ShipmentCreate() {
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
@@ -449,7 +439,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -460,6 +450,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
@@ -748,9 +739,7 @@ export function ShipmentCreate() {
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">{error}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -328,7 +328,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
|
||||
@@ -391,7 +391,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo}</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ShipmentList() {
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
||||
const [scheduleView, setScheduleView] = useState<CalendarView>('week-time');
|
||||
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
||||
|
||||
// startDate 변경 시 캘린더 월 자동 이동
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user