feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서

- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력)
- MES 데이터 정합성 분석 보고서 v1/v2
- sam-docs 프론트엔드 기술문서 v1 (9개 챕터)
- claudedocs 가이드/테스트URL 업데이트
This commit is contained in:
유병철
2026-03-13 17:52:57 +09:00
parent 80164f722e
commit c309ac479f
27 changed files with 6383 additions and 31 deletions

View File

@@ -0,0 +1,498 @@
# MES 데이터 정합성 심층 분석 보고서 v2
**분석일**: 2026-03-13 (v2 - 코드 업데이트 반영)
**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인
**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 재분석
---
## v1 대비 변경사항 요약
| 항목 | v1 (초기 분석) | v2 (코드 업데이트 반영) |
|------|---------------|----------------------|
| StockLot.work_order_id FK | 확인 안됨 | ✅ 2026-02-21 추가 확인 (생산→재고 연결 기반 마련) |
| QualityDocument 시스템 | 존재 인지 | ✅ 2026-03-05~10 활발히 개선 중 (inspection_data, options JSON 추가) |
| 출하 자동생성 | 언급 | ✅ 상세 분석 완료: createShipmentFromOrder() 중복방지 + ensureShipmentExists() |
| 3월 MES FK 추가 | 미확인 | ❌ 3월 마이그레이션에 MES FK 추가 없음 확인 |
| 나머지 4개 이슈 | 발견 | 🔴 여전히 미해결 (can_ship, LOT, ShipmentItem FK) |
---
## Executive Summary
| # | 이슈 | 심각도 | v1 판정 | v2 판정 | 변경 |
|---|------|--------|---------|---------|------|
| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡→🟢 | 조건부 동작 | **정상 동작 + 자동출하 생성** | ⬆ 개선 |
| 2 | 품질검사 이중 시스템 | 🔴 | 구조적 문제 | 🔴 **구조적 문제 지속** (QualityDocument 활발 개발 중이나 출고 연동 미완) | 유지 |
| 3 | 출고 시 can_ship 검증 | 🔴 | 누락 | 🔴 **여전히 누락** (canProceedToShip 호출 0회) | 유지 |
| 4 | 출고 시 재고 차감 | ✅ | 구현됨 | ✅ **구현됨**, ⚠️ soft fail 리스크 유지 | 유지 |
| 5 | LOT 추적 체계 | 🔴 | 단절 | 🟡 **부분 개선** (StockLot.work_order_id FK 추가, 그러나 LOT 전달 로직 미구현) | ⬆ 부분 |
| 6 | 출고품목↔수주품목 FK | 🔴 | 없음 | 🔴 **여전히 없음** (3월 마이그레이션에도 미추가) | 유지 |
---
## 이슈 1: 생산완료 → 수주 상태 자동전환 + 출하 자동생성
### v2 판정: 🟢 정상 동작 (v1 대비 상향)
v2 분석에서 자동 출하 생성 로직까지 상세 확인 완료. **정상 동작 확인**.
### 전체 흐름
```
WorkOrder 상태 변경 (updateStatus)
syncOrderStatus() 자동 호출 (L971-1059)
메인 WO 필터링: is_auxiliary=false AND process_id≠null
전체 완료 시 → Order.status = PRODUCED
createShipmentFromOrder() 자동 호출 (L719-809)
Shipment 생성: status='scheduled', can_ship=true(자동)
기존 Shipment 있으면 → 중복 생성 방지 (L721-728)
```
### 코드 근거
**syncOrderStatus**: `WorkOrderService.php:971-1059`
```php
// L989-995: 메인 WO 필터 (보조공정 + process_id=null 제외)
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) =>
!$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null
);
// L1001-1019: 상태 결정
if ($shippedCount === $totalCount) {
$newOrderStatus = Order::STATUS_SHIPPED;
} elseif (($completedCount + $shippedCount) === $totalCount) {
$newOrderStatus = Order::STATUS_PRODUCED;
}
```
**createShipmentFromOrder**: `WorkOrderService.php:719-809`
```php
// L721-728: 중복 방지
$existingShipment = Shipment::where('order_id', $order->id)->first();
if ($existingShipment) return $existingShipment;
// L732-744: 출하 자동 생성
$shipment = Shipment::create([
'order_id' => $order->id,
'work_order_id' => null, // 수주 레벨 (WO 레벨 아님)
'status' => 'scheduled',
'can_ship' => true, // ← 자동으로 true 설정
]);
// L746-790: WO 아이템 → ShipmentItem 복사
```
**ensureShipmentExists**: 이미 PRODUCED인데 출하가 없는 경우 보완 (L1027-1033)
### 잔존 리스크 (낮음)
| 조건 | 원인 | 발생 가능성 |
|------|------|------------|
| `process_id = NULL`인 WO | 공정 매핑 실패 | 낮음 (생성 시 검증됨) |
| `is_auxiliary` 오설정 | options JSON 수동 수정 | 매우 낮음 |
### 회의 논의 포인트
- ✅ 이 부분은 정상 동작 확인됨. 추가 조치 불필요
- (선택) process_id=null WO가 실데이터에 존재하는지 한번 쿼리 확인
---
## 이슈 2: 품질검사 이중 시스템
### v2 판정: 🔴 구조적 문제 지속 (QualityDocument 활발 개발 중이나 출고 연동은 미완)
### v1 대비 변화
| 변경 사항 | 시기 | 내용 |
|-----------|------|------|
| `quality_document_locations.inspection_data` JSON 추가 | 2026-03-06 | 개소별 검사 데이터 저장 |
| `quality_document_locations.options` JSON 추가 | 2026-03-10 | 검사 옵션 확장 |
| QualityDocumentService 개선 | 2026-03 | inspectLocation() 등 기능 확장 |
### 여전히 해결 안 된 핵심 문제
```
QualityDocument.complete() 호출 시:
→ inspection_status = 'completed' (QualityDocument 내부만 업데이트)
→ ❌ Shipment.can_ship 업데이트 없음
→ ❌ Inspection 테이블 동기화 없음
```
**두 시스템 현재 상태**:
| 항목 | 경로A: Inspection | 경로B: QualityDocument |
|------|-------------------|----------------------|
| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` |
| **FK 연결** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) |
| **3월 업데이트** | 변경 없음 | ✅ 활발히 개선 중 |
| **출고 참조** | ❌ 안됨 | ❌ 안됨 |
### 회의 논의 포인트
- QualityDocument가 활발히 개발 중 → **경로B를 표준으로 확정하는 것이 합리적**
- 품질 완료 시 Shipment.can_ship 자동 업데이트 연동 필요
- 경로A(Inspection)는 IQC/PQC 전용으로 역할 한정, FQC는 경로B로 통일
---
## 이슈 3: 출고 시 can_ship 검증 누락
### v2 판정: 🔴 여전히 미해결 (canProceedToShip() 호출 0회 확인)
### 코드 현황 (변경 없음)
**canProceedToShip()**: `Shipment.php:220-223` — 정의만 존재
```php
public function canProceedToShip(): bool {
return $this->can_ship && $this->deposit_confirmed;
}
// grep 결과: 모델 정의 외 호출 0회
```
**updateStatus()**: `ShipmentService.php:305-356` — can_ship 검증 없이 바로 업데이트
```php
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
{
$shipment = Shipment::findOrFail($id);
// 🔴 can_ship 검증 없음
$shipment->update(['status' => $status, ...]);
}
```
**프론트엔드**: `ShipmentDetail.tsx:304-314` — can_ship 무시하고 버튼 표시
```typescript
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
scheduled: 'ready', ready: 'shipping', shipping: 'completed', completed: null,
};
// can_ship=false여도 상태 변경 버튼 표시됨
```
### v2 신규 발견: 자동 출하에서 can_ship=true 자동 설정
```php
// createShipmentFromOrder (L732-744)
'can_ship' => true, // 자동 생성 시 무조건 true
```
→ 자동 생성된 출하는 can_ship=true이므로 문제 경감
**그러나** 수동 생성 출하에서는 여전히 검증 없음
### 위험 시나리오
```
수동 출하 생성 (can_ship=false)
→ 사용자가 "출하대기" 클릭 → 검증 없이 ready
→ "배송중" → "배송완료" → 재고 차감 시도
→ 재고 부족 시 soft fail (로그만, 상태는 completed) ❌
```
### 수정안 (최소 변경)
**백엔드** (1곳 수정):
```php
// ShipmentService::updateStatus() 시작부에 추가
if (in_array($status, ['ready', 'shipping', 'completed']) && !$shipment->can_ship) {
throw new \Exception('출하 불가 상태입니다. 품질 검수를 완료해주세요.');
}
```
**프론트엔드** (1곳 수정):
```typescript
// ShipmentDetail.tsx 버튼 표시 조건
{STATUS_TRANSITIONS[detail.status] && detail.canShip && (
<Button onClick={handleOpenStatusDialog}>변경</Button>
)}
```
---
## 이슈 4: 출고 시 재고 차감
### v2 판정: ✅ 구현됨, ⚠️ Soft Fail 리스크 유지 (변경 없음)
**코드**: `ShipmentService.php:361-401`
```php
private function decreaseStockForShipment(Shipment $shipment): void
{
foreach ($items as $item) {
try {
$stockService->decreaseForShipment(...);
} catch (\Exception $e) {
// 🟡 SOFT FAIL: 로그만 기록, 출하 상태는 completed 유지
Log::warning('Failed to decrease stock', [...]);
// throw 없음 → 다음 아이템으로 계속
}
}
}
```
### 회의 논의 포인트
- **Hard Fail 전환 여부**: `throw`로 변경하면 하나라도 실패 시 출하 전체 롤백
- **현재 방식 장점**: 일부 품목 재고 부족해도 출하는 진행 가능
- **권장**: 최소한 재고 차감 실패 건수를 프론트에 표시 + 관리자 알림
---
## 이슈 5: LOT 추적 체계
### v2 판정: 🟡 부분 개선 (v1 🔴 → v2 🟡)
### v1 대비 개선 사항
| 개선 | 시기 | 코드 근거 |
|------|------|-----------|
| `stock_lots.work_order_id` FK 추가 | 2026-02-21 | 마이그레이션 확인 |
| `inspections.work_order_id` FK 추가 | 2026-02-27 | 마이그레이션 확인 |
→ 재고↔생산, 검사↔생산 연결 **기반은 마련됨**
### 여전히 해결 안 된 핵심 문제
**1. 프론트에서 LOT 생성 → 백엔드 전송 안 됨**
```typescript
// WorkerScreen/actions.ts:246 — 프론트에서만 LOT 생성
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
// ← 이 값이 API 요청 body에 포함되지 않음
```
**2. 백엔드 LOT 저장 로직 없음**
```php
// WorkOrderService.php:578-583
case WorkOrder::STATUS_COMPLETED:
$workOrder->completed_at = now();
$this->saveItemResults($workOrder, $resultData, $userId);
// ❌ LOT 자동 채번/저장 로직 없음
break;
```
**3. 생산입고 시 LOT 전달 실패**
```php
// WorkOrderService.php:620-637
private function stockInFromProduction(WorkOrder $workOrder): void {
foreach ($workOrder->items as $woItem) {
$lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값
if ($goodQty > 0 && $lotNo) { // ← 조건 불충족 → 실행 안됨
$this->stockService->increaseFromProduction(...);
}
}
}
```
**StockLot.work_order_id FK는 추가됐지만, 실제 LOT를 생성/저장하는 코드가 없어서 FK가 활용되지 않음**
### LOT 추적 현황 (업데이트)
```
수주 KD-TS-260313-01
→ 생산 완료 (LOT 미생성 ❌ — 프론트에서만 생성, 백엔드 저장 안됨)
→ 재고 입고 (LOT 전달 실패 ❌ — stockInFromProduction 조건 불충족)
→ [신규] StockLot.work_order_id FK 존재 (✅ 기반 마련)
→ 품질검사 (별도 LOT 입력 ⚠️)
→ 출고 (자재 LOT만 선택 가능 ❌, 생산 LOT 없음)
```
### 수정 방향 (StockLot.work_order_id 활용)
```php
// 1. 백엔드에서 LOT 자동 채번 (WorkOrderService)
$lotNo = $this->numberingService->generate('production-lot', $tenantId);
// 2. saveItemResults()에서 lot_no 저장
$woItem->options = array_merge($woItem->options, ['result' => ['lot_no' => $lotNo]]);
// 3. stockInFromProduction()에서 정상 동작 → StockLot 생성 시 work_order_id 연결
$this->stockService->increaseFromProduction(
lotNo: $lotNo,
workOrderId: $workOrder->id // ← 이미 FK 존재
);
```
---
## 이슈 6: 출고품목 ↔ 수주품목 FK 부재
### v2 판정: 🔴 여전히 미해결 (3월 마이그레이션에도 미추가 확인)
### ShipmentItem 실제 컬럼 (변경 없음)
```
id, tenant_id, shipment_id(FK), seq,
item_code, item_name, floor_unit, specification,
quantity, unit, lot_no, stock_lot_id(index only), remarks
```
-`order_item_id` → 없음
-`work_order_item_id` → 없음
### 3월 마이그레이션 확인 결과
3월에 추가된 마이그레이션 중 `shipment_items` 관련 변경 **0건**.
주요 3월 마이그레이션은 QualityDocument 관련 (`inspection_data`, `options` JSON 추가)에 집중.
### 추적 불가 질문들 (여전히)
| 질문 | 답변 가능 여부 |
|------|--------------|
| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 |
| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 |
| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 |
| "부분 출고 진행률은?" | ❌ 계산 불가 |
### 자동 출하 생성 시 연결 기회 놓침
```php
// createShipmentFromOrder (L746-790)
// WO 아이템을 ShipmentItem으로 복사하면서 source 정보 저장 안 함
ShipmentItem::create([
'shipment_id' => $shipment->id,
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
'quantity' => $result['good_qty'] ?? $woItem->quantity,
// ❌ 'order_item_id' => $woItem->source_order_item_id ← 이것만 추가하면 됨
// ❌ 'work_order_item_id' => $woItem->id ← 이것만 추가하면 됨
]);
```
### 수정안
**마이그레이션** (새 파일):
```php
Schema::table('shipment_items', function (Blueprint $table) {
$table->unsignedBigInteger('order_item_id')->nullable()->after('stock_lot_id');
$table->unsignedBigInteger('work_order_item_id')->nullable()->after('order_item_id');
$table->foreign('order_item_id')->references('id')->on('order_items')->nullOnDelete();
$table->foreign('work_order_item_id')->references('id')->on('work_order_items')->nullOnDelete();
});
```
**createShipmentFromOrder** (2줄 추가):
```php
ShipmentItem::create([
...기존 필드,
'order_item_id' => $woItem->source_order_item_id, // 추가
'work_order_item_id' => $woItem->id, // 추가
]);
```
---
## 전체 FK 연결 현황도 (v2 업데이트)
```
orders ──────────────────── order_items ──────── order_nodes
│ (order_id FK) │ (order_node_id FK) │
│ │ │
├─── work_orders │ │
│ │ (sales_order_id FK) │ │
│ │ │ │
│ └─── work_order_items │ │
│ │ │ │
│ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만)
│ │ │
│ inspections │
│ │ (work_order_id FK ✅) [2026-02-27 추가] │
│ │ (lot_no ← 연결 안됨 ❌) │
│ │
│ stock_lots │
│ │ (work_order_id FK ✅) [2026-02-21 추가] ← 🆕 v1에서 미확인
│ │
├─── quality_document_orders ──→ quality_documents │
│ │ (order_id FK ✅) │
│ │ │
│ └─── quality_document_locations │
│ │ (order_item_id FK ✅) │
│ │ (inspection_data JSON 🆕 2026-03-06) │
│ │ (options JSON 🆕 2026-03-10) │
│ │
└─── shipments │
│ (order_id FK ✅, work_order_id FK ✅) │
│ │
└─── shipment_items │
│ (shipment_id FK ✅) │
│ (stock_lot_id → 인덱스만, FK 없음) │
│ (order_item_id ❌ 컬럼 없음) │
│ (work_order_item_id ❌ 컬럼 없음) │
```
---
## 개선 우선순위 로드맵 (v2 업데이트)
### P0 (즉시 - 운영 리스크) — 변경 없음
| # | 작업 | 수정 범위 | 난이도 |
|---|------|---------|--------|
| 1 | **can_ship 검증 추가** | ShipmentService::updateStatus() 1곳 + ShipmentDetail.tsx 1곳 | 하 (수정 3줄) |
| 2 | **재고 차감 실패 알림** | ShipmentService::decreaseStockForShipment() → 최소 결과 반환 | 하 |
### P1 (단기 - 데이터 정합성) — 🆕 StockLot.work_order_id 활용 추가
| # | 작업 | 수정 범위 | 난이도 |
|---|------|---------|--------|
| 3 | **생산 LOT 백엔드 자동 채번** | WorkOrderService::saveItemResults() + NumberingService | 중 |
| 4 | **생산입고 LOT 연결** | WorkOrderService::stockInFromProduction() → StockLot.work_order_id 활용 | 중 |
| 5 | **shipment_items에 order_item_id 추가** | 마이그레이션 + createShipmentFromOrder() 2줄 추가 | 중 |
### P2 (중기 - 구조 개선) — 🆕 QualityDocument 기반 통합 명시
| # | 작업 | 수정 범위 | 난이도 |
|---|------|---------|--------|
| 6 | **품질검사 정본 = QualityDocument** | Inspection은 IQC/PQC 전용, FQC는 QualityDocument로 통일 | 상 |
| 7 | **품질완료 → can_ship 자동 연동** | QualityDocumentService::complete() → Shipment.can_ship 업데이트 | 중 |
| 8 | **work_order_items.source_order_item_id FK** | 마이그레이션 1줄 | 하 |
| 9 | **stock_lot_id FK constraint 추가** | shipment_items 마이그레이션 | 하 |
---
## 정상 동작 확인 항목 (v2)
- ✅ 수주 → 생산지시 생성 (공정별 자동 분류)
- ✅ 작업지시 상태 관리 (유효 상태 전환 + auxiliary 필터링)
-**syncOrderStatus()**: 메인 WO 완료 → Order PRODUCED 자동 전환
-**createShipmentFromOrder()**: PRODUCED 전환 시 출하 자동 생성 (중복 방지 포함)
-**ensureShipmentExists()**: 이미 PRODUCED인데 출하 없는 경우 보완
- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService)
- ✅ 출고 완료 시 재고 차감 (FIFO + lockForUpdate + stock_transactions)
- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환
- ✅ 매출 자동 생성 (sales_recognition 조건부)
- ✅ 수주 상태별 수정/삭제 제한
- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제)
- 🆕 ✅ StockLot.work_order_id FK (생산→재고 연결 기반)
- 🆕 ✅ Inspection.work_order_id FK (검사→생산 연결)
---
## 회의 토론 안건 정리
### 즉시 결정 필요 (P0)
1. **can_ship 검증**: 백엔드 1줄 + 프론트 1줄 수정으로 해결 가능. 즉시 적용?
2. **재고 차감 실패 처리**: Hard fail(롤백) vs Soft fail(현행) + 알림 추가?
### 설계 방향 결정 필요 (P1)
3. **LOT 채번 규칙**: 생산 LOT 형식 결정 (현재 프론트: `KD-SA-YYMMDD-NN`)
4. **생산 LOT 생성 시점**: WO 완료 시? WO 생성 시? 첫 작업 보고 시?
5. **ShipmentItem FK**: 마이그레이션 타이밍 (기존 데이터 소급 매칭 필요?)
### 방향성 논의 (P2)
6. **품질 시스템 정본**: QualityDocument를 표준으로 확정하는 것에 이견 있는지?
7. **품질→출하 자동 연동**: 어떤 조건에서 can_ship=true로 전환할 것인지?
- 전체 개소(location) 검사 완료 시?
- 합격률 기준?
- 수동 최종 승인 필요?

View File

@@ -0,0 +1,421 @@
# MES 데이터 정합성 심층 분석 보고서
**분석일**: 2026-03-13
**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인
**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 분석
---
## Executive Summary
| # | 이슈 | 심각도 | 현황 | 코드 근거 |
|---|------|--------|------|-----------|
| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡 조건부 동작 | 로직 있으나 edge case에서 실패 가능 | `WorkOrderService.php:974-1062` |
| 2 | 품질검사 이중 시스템 | 🔴 구조적 문제 | Inspection vs QualityDocument 분리, 출고 연동 없음 | 양쪽 모두 Shipment 참조 안함 |
| 3 | 출고 시 can_ship 검증 | 🔴 누락 | canProceedToShip() 정의만 있고 호출 0회 | `ShipmentService.php:305-356` |
| 4 | 출고 시 재고 차감 | ✅ 구현됨 | completed 전환 시 FIFO 자동 차감 | `ShipmentService.php:361-401` |
| 5 | LOT 추적 체계 | 🔴 단절 | 프론트에서만 LOT 생성, 백엔드 저장 안됨 | `WorkerScreen/actions.ts:246` |
| 6 | 출고품목↔수주품목 FK | 🔴 없음 | ShipmentItem에 order_item_id 컬럼 자체 부재 | `shipment_items 마이그레이션` |
---
## 이슈 1: 생산완료 → 수주 상태 자동전환
### 결론: ✅ 로직 있음, 🟡 조건부 실패 가능
### 동작 원리
```
WorkOrder 상태 변경 (updateStatus)
↓ (라인 603)
syncOrderStatus() 자동 호출
↓ (라인 1004-1022)
메인 작업지시 집계 → 조건 충족 시 Order.status = PRODUCED
↓ (라인 1059-1061)
PRODUCED 전환 시 → 출고(Shipment) 자동 생성
```
**코드**: `sam-api/app/Services/WorkOrderService.php`
```php
// 라인 998: 메인 작업지시 필터
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) =>
!$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null
);
// 라인 1011-1022: 상태 결정
if ($shippedCount === $totalCount) {
$newOrderStatus = Order::STATUS_SHIPPED;
} elseif (($completedCount + $shippedCount) === $totalCount) {
$newOrderStatus = Order::STATUS_PRODUCED; // ← 핵심 조건
} elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) {
$newOrderStatus = Order::STATUS_IN_PRODUCTION;
}
```
### 실패 가능 조건
| 조건 | 원인 | 영향 |
|------|------|------|
| `process_id = NULL`인 WO 존재 | 공정 매핑 실패로 생성된 작업지시 | 메인 WO 카운트에서 제외 → 조건식 계산 오류 |
| `is_auxiliary = true` 오설정 | options JSON에 잘못 저장 | 메인 WO로 인식 안 됨 |
### 검증 SQL
```sql
-- 해당 수주의 작업지시 현황 확인
SELECT id, work_order_no, status, process_id,
JSON_EXTRACT(options, '$.is_auxiliary') as is_auxiliary
FROM work_orders
WHERE sales_order_id = {order_id} AND status != 'cancelled';
```
### 회의 논의 포인트
- process_id=null인 작업지시가 실제로 존재하는지 DB 확인 필요
- 존재한다면 → 생산지시 생성 시 process_id null 방지 로직 추가
---
## 이슈 2: 품질검사 이중 시스템
### 결론: 🔴 두 시스템이 독립 운영, 출고와 연동 없음
### 두 시스템 비교
| 항목 | 경로A: Inspection | 경로B: QualityDocument |
|------|-------------------|----------------------|
| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` |
| **생성일** | 2025-12-29 | 2026-03-05 (최근 추가) |
| **연결 키** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) |
| **판정 필드** | `result: pass/fail` | `inspection_status: pending/completed` |
| **검사 단위** | 전체 건 | 개소(location)별 |
| **프론트 진입점** | 검사 메뉴 | 제품검사 메뉴 |
| **FQC 문서** | JSON items 배열 | Document 시스템 (EAV) |
| **출고 참조** | ❌ 안됨 | ❌ 안됨 |
### 핵심 문제: 출고에서 둘 다 참조 안함
**코드**: `sam-api/app/Services/ShipmentService.php`
```php
// 라인 207: 출고 생성 시
'can_ship' => $data['can_ship'] ?? false, // ← 수동 입력만, 품질 검사 결과 참조 없음
// 라인 220-223: 출고 가능 여부 메서드
public function canProceedToShip(): bool {
return $this->can_ship && $this->deposit_confirmed;
// ❌ Inspection.result 참조 없음
// ❌ QualityDocumentLocation.inspection_status 참조 없음
}
```
### 프론트엔드 판정 우선순위
**코드**: `src/components/quality/InspectionManagement/InspectionDetail.tsx`
```
경로B (QualityDocument/FQC 문서) 우선 → 경로A (Inspection) fallback
```
### 회의 논의 포인트
- **정본 결정 필요**: 경로A(Inspection) vs 경로B(QualityDocument) 중 하나를 표준으로
- 경로B가 최근(3월) 추가된 것 → 경로B를 표준으로 하고 경로A는 호환 레이어?
- 출고 시 품질 판정 자동 참조 로직 추가 필수
---
## 이슈 3: 출고 시 can_ship 검증 누락
### 결론: 🔴 canProceedToShip() 메서드 정의만 있고, 실제 호출 0회
### 현재 상태 변경 코드
**코드**: `sam-api/app/Services/ShipmentService.php:305-356`
```php
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
{
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
// 🔴 can_ship 검증 로직 전혀 없음
$shipment->update(['status' => $status, ...]); // ← 바로 업데이트
// completed 시 재고 차감 (이것은 동작함)
if ($status === 'completed' && $previousStatus !== 'completed') {
$this->decreaseStockForShipment($shipment);
}
}
```
### 프론트엔드도 미검증
**코드**: `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx:304-314`
```typescript
// 상태 전이 맵만 확인, canShip 체크 없음
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
scheduled: 'ready',
ready: 'shipping',
shipping: 'completed',
completed: null,
};
// can_ship=false여도 버튼이 표시됨 ❌
{STATUS_TRANSITIONS[detail.status] && (
<Button onClick={handleOpenStatusDialog}>변경</Button>
)}
```
### 위험 시나리오
```
can_ship=false (품질 미통과) + status=scheduled
→ 사용자가 "출하대기로 변경" 클릭
→ 백엔드 검증 없음 → status='ready' ❌
→ "배송중" → "배송완료" → 재고 차감 시도
→ 재고 부족 시 soft fail (로그만 기록, 상태는 변경됨) ❌
```
### 회의 논의 포인트
- 백엔드: `updateStatus()``can_ship` 검증 추가 (1줄 수정)
- 프론트: 버튼 표시 조건에 `detail.canShip` 추가
- 재고 차감 실패 시 hard fail로 변경할지 논의 필요
---
## 이슈 4: 출고 시 재고 차감
### 결론: ✅ 완전 구현됨
**코드**: `sam-api/app/Services/ShipmentService.php:347-350`
```php
if ($status === 'completed' && $previousStatus !== 'completed') {
$this->decreaseStockForShipment($shipment);
}
```
**StockService FIFO 차감**: `StockService.php:1236-1354`
- Stock 행 잠금 (lockForUpdate)
- LOT별 FIFO 순서 차감
- stock_transactions 거래 기록 (reason: SHIPMENT)
- 감사 로그 기록
**⚠️ 주의**: 개별 품목 차감 실패 시 soft fail (로그만 기록, 트랜잭션 미롤백)
---
## 이슈 5: LOT 추적 체계 단절
### 결론: 🔴 4개 모듈이 완전 독립적 LOT 관리, 추적 불가
### LOT 생성/관리 현황
| 모듈 | LOT 형식 | 생성 위치 | 저장 위치 | 상태 |
|------|----------|-----------|-----------|------|
| **수주** | - | - | Order에 lot_no 필드 없음 | ❌ 필드 없음 |
| **생산** | `KD-SA-YYMMDD-NN` | 프론트 `WorkerScreen/actions.ts:246` | ❌ 백엔드 전송 안됨 | ❌ 저장 안됨 |
| **자재** | 입고 시 생성 | `StockService` | `stock_lots.lot_no` | ✅ 동작 |
| **품질** | 검사팀 별도 입력 | `InspectionService` | `inspections.lot_no` | ⚠️ 연결 없음 |
| **출고** | StockLot에서 선택 | `ShipmentService:getLotOptions()` | `shipments.lot_no` | ⚠️ 자재 LOT만 |
### 핵심 단절 코드
**프론트에서 LOT 생성하지만 전송 안 함**:
```typescript
// WorkerScreen/actions.ts:246
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
// ← 이 값이 API 요청에 포함되지 않음
```
**백엔드에서 LOT 저장 안 함**:
```php
// WorkOrderService.php:578-583
case WorkOrder::STATUS_COMPLETED:
$workOrder->completed_at = now();
$this->saveItemResults($workOrder, $resultData, $userId);
// ❌ LOT 생성/저장 로직 없음
break;
```
**생산입고 시 LOT 전달 실패**:
```php
// WorkOrderService.php:620-637
private function stockInFromProduction(WorkOrder $workOrder): void {
foreach ($workOrder->items as $woItem) {
$lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값
if ($goodQty > 0 && $lotNo) { // ← 조건 불충족으로 실행 안됨
$this->stockService->increaseFromProduction(...);
}
}
}
```
**출고 LOT 옵션에서 생산 LOT 제외**:
```php
// ShipmentService.php:525-550
public function getLotOptions(): array {
return StockLot::where(...) // ← 구매입고 LOT만 조회
->whereIn('status', ['available', 'reserved'])
->get();
// ❌ 생산 완료 LOT(KD-SA-*) 미포함
}
```
### 추적 불가 시나리오
```
수주 KD-TS-260313-01
→ 생산 완료 (LOT 미생성)
→ 재고 입고 (LOT 전달 실패 → 입고 안됨?)
→ 품질검사 (별도 LOT 입력)
→ 출고 (자재 LOT만 선택 가능, 생산품 LOT 없음)
결과: "이 출고 건이 어느 생산 LOT인지" → 답 불가
```
### 회의 논의 포인트
- **최우선**: 백엔드에서 생산 LOT 자동 채번/저장 로직 구현
- WorkResult.lot_no에 실제 저장
- StockLot.work_order_id (이미 2026-02-21 추가됨) 활용하여 연결
- getLotOptions()에 생산 LOT 포함
---
## 이슈 6: 출고품목 ↔ 수주품목 FK 부재
### 결론: 🔴 ShipmentItem에 order_item_id, work_order_item_id 컬럼 자체가 없음
### ShipmentItem 실제 컬럼
**마이그레이션**: `2025_12_26_150605_create_shipment_items_table.php`
```
id, tenant_id, shipment_id(FK), seq,
item_code, item_name, floor_unit, specification,
quantity, unit, lot_no, stock_lot_id(FK), remarks
```
-`order_item_id` → 없음
-`work_order_item_id` → 없음
- 품목 데이터는 **텍스트 복사**만 (품명, 규격, 수량)
### ShipmentItem 생성 코드
**코드**: `sam-api/app/Services/ShipmentService.php:468-493`
```php
ShipmentItem::create([
'item_code' => $item['item_code'] ?? null,
'item_name' => $item['item_name'],
'quantity' => $item['quantity'] ?? 0,
// ❌ order_item_id 없음
// ❌ work_order_item_id 없음
]);
```
### 추적 불가 질문들
| 질문 | 답변 가능 여부 |
|------|--------------|
| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 |
| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 |
| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 |
| "부분 출고 진행률은?" | ❌ 계산 불가 |
### 관련 FK도 불완전
**WorkOrderItem.source_order_item_id**: 인덱스만 있고 FK constraint 없음
```php
// 마이그레이션 2026_01_16
$table->unsignedBigInteger('source_order_item_id')->nullable();
$table->index('source_order_item_id'); // ← 인덱스만
// ❌ $table->foreign('source_order_item_id')->references('id')->on('order_items') 없음
```
### 회의 논의 포인트
- shipment_items에 `order_item_id`, `work_order_item_id` 컬럼 추가 마이그레이션
- 기존 데이터 마이그레이션 방안 (품명+규격으로 매칭?)
- work_order_items.source_order_item_id에 FK constraint 추가
---
## 전체 FK 연결 현황도
```
orders ──────────────────── order_items ──────── order_nodes
│ (order_id FK) │ (order_node_id FK) │
│ │ │
├─── work_orders │ │
│ │ (sales_order_id FK) │ │
│ │ │ │
│ └─── work_order_items │ │
│ │ │ │
│ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만)
│ │ │
│ inspections │
│ │ (work_order_id FK ✅) │
│ │ (lot_no ← 연결 안됨 ❌) │
│ │
├─── quality_document_orders ──→ quality_documents │
│ │ (order_id FK ✅) │
│ │ │
│ └─── quality_document_locations │
│ │ (order_item_id FK ✅) │
│ │
└─── shipments │
│ (order_id FK ✅, work_order_id FK ✅) │
│ │
└─── shipment_items │
│ (shipment_id FK ✅) │
│ (stock_lot_id FK ✅) │
│ (order_item_id ❌ 없음) │
│ (work_order_item_id ❌ 없음) │
```
---
## 개선 우선순위 로드맵
### P0 (즉시 - 운영 리스크)
| # | 작업 | 영향범위 | 예상 난이도 |
|---|------|---------|------------|
| 1 | **can_ship 검증 추가** (백엔드 updateStatus + 프론트 버튼 조건) | ShipmentService 1곳 + ShipmentDetail 1곳 | 하 |
| 2 | **재고 차감 실패 시 hard fail** (try-catch에서 throw로 변경) | ShipmentService 1곳 | 하 |
### P1 (단기 - 데이터 정합성)
| # | 작업 | 영향범위 | 예상 난이도 |
|---|------|---------|------------|
| 3 | **생산 LOT 백엔드 자동 채번/저장** | WorkOrderService + NumberingService | 중 |
| 4 | **생산입고 LOT 연결 수정** (stockInFromProduction) | WorkOrderService + StockService | 중 |
| 5 | **shipment_items에 order_item_id 추가** (마이그레이션 + 서비스) | 마이그레이션 + ShipmentService | 중 |
### P2 (중기 - 구조 개선)
| # | 작업 | 영향범위 | 예상 난이도 |
|---|------|---------|------------|
| 6 | **품질검사 정본 결정** (Inspection vs QualityDocument 통합) | 양쪽 서비스 + 프론트 | 상 |
| 7 | **출고 시 품질 판정 자동 참조** (can_ship 자동 설정) | ShipmentService + 품질 연동 | 상 |
| 8 | **work_order_items.source_order_item_id FK 추가** | 마이그레이션 | 하 |
| 9 | **process_id=null 작업지시 생성 방지** | OrderService.createProductionOrder | 하 |
---
## 참고: 정상 동작하는 부분
- ✅ 수주 → 생산지시 생성 (공정별 자동 분류)
- ✅ 작업지시 상태 관리 (유효한 상태 전환 규칙)
- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService)
- ✅ 출고 완료 시 재고 차감 (FIFO + 거래 기록)
- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환
- ✅ 매출 자동 생성 (sales_recognition 조건부)
- ✅ 수주 상태별 수정/삭제 제한
- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제)

View File

@@ -152,7 +152,21 @@ http://localhost:3000/ko/quality/equipment-repairs # 🆕 수리이력
---
## 🚗 차량/지게차 (Vehicle Management)
## 🚗 차량관리 (Vehicle)
| 페이지 | URL | 상태 |
|--------|-----|------|
| **법인차량관리** | `/ko/vehicle/corporate-vehicles` | 🆕 NEW |
| **차량일지** | `/ko/vehicle/vehicle-logs` | 🆕 NEW |
| **정비이력** | `/ko/vehicle/vehicle-maintenance` | 🆕 NEW |
```
http://localhost:3000/ko/vehicle/corporate-vehicles # 🆕 법인차량관리
http://localhost:3000/ko/vehicle/vehicle-logs # 🆕 차량일지
http://localhost:3000/ko/vehicle/vehicle-maintenance # 🆕 정비이력
```
### 이전 차량/지게차 (레거시)
| 페이지 | URL | 상태 |
|--------|-----|------|
@@ -409,7 +423,14 @@ http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리
```
### Vehicle Management (차량/지게차)
### Vehicle (차량관리)
```
http://localhost:3000/ko/vehicle/corporate-vehicles # 🆕 법인차량관리
http://localhost:3000/ko/vehicle/vehicle-logs # 🆕 차량일지
http://localhost:3000/ko/vehicle/vehicle-maintenance # 🆕 정비이력
```
### Vehicle Management (레거시 차량/지게차)
```
http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리
http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록
@@ -536,7 +557,12 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
'/outbound/shipments' // 출하관리 (🆕 NEW)
'/outbound/vehicle-dispatches' // 배차차량관리 (🆕 NEW)
// Vehicle Management (차량/지게차)
// Vehicle (차량관리)
'/vehicle/corporate-vehicles' // 법인차량관리 (🆕 NEW)
'/vehicle/vehicle-logs' // 차량일지 (🆕 NEW)
'/vehicle/vehicle-maintenance' // 정비이력 (🆕 NEW)
// Vehicle Management (레거시 차량/지게차)
'/vehicle-management/vehicle' // 차량관리 (🆕 NEW)
'/vehicle-management/vehicle-log' // 차량일지/월간사진기록 (🆕 NEW)
'/vehicle-management/forklift' // 지게차 관리 (🆕 NEW)

View File

@@ -333,7 +333,7 @@ IntegratedListTemplateV2를 사용하는 페이지를 만들거나 리팩토링
| 6 | **테이블 행** | `renderTableRow` (TableRow + TableCell 조합) | ✅ |
| 7 | **헤더 레이아웃** | 순서: `[검색] [날짜/연월] --- [액션버튼] [등록버튼]` | ✅ |
| 8 | **통계 카드** | `stats` 배열 (label, value, icon, iconColor) | 권장 |
| 9 | **테이블 내 필터** | `tableHeaderActions` (부서, 상태 등 Select) | 필요 시 |
| 9 | **테이블 내 필터** | `filterConfig` 통합 필터 사용 (PC: 인라인, 모바일: 바텀시트 자동 분기). `tableHeaderActions`에 Select 직접 넣기 금지 | ✅ |
| 10 | **탭** | `tabsContent` (커스텀) 또는 `tabs` + `activeTab` + `onTabChange` | 필요 시 |
### 컬럼 설정 (필수 패턴)
@@ -421,42 +421,89 @@ dateRangeSelector={{
}}
```
### 테이블 내 필터 (tableHeaderActions)
### 🔴 테이블 내 필터 — filterConfig 통합 방식 (필수)
테이블 카드 내부 "전체 N건" 오른쪽에 필터 셀렉트를 배치한다:
테이블 카드 내부 필터는 **반드시 `filterConfig` 통합 필터 시스템**을 사용한다.
- PC(xl 이상): 인라인 Select로 자동 렌더링
- 모바일/태블릿(xl 미만): 바텀시트(`MobileFilter`)로 자동 분기
**❌ 금지 패턴**: `tableHeaderActions`에 직접 Select를 넣으면 **모바일에서 필터가 보이지 않는다**.
```tsx
const tableHeaderActionsNode = (
<div className="flex items-center gap-2">
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
<SelectTrigger className="min-w-[120px] w-auto">
<SelectValue placeholder="전체 부서" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 부서</SelectItem>
{departments.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={handleStatusChange}>
<SelectTrigger className="min-w-[120px] w-auto">
<SelectValue placeholder="전체 상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체 상태</SelectItem>
<SelectItem value="draft">작성중</SelectItem>
<SelectItem value="confirmed">확정</SelectItem>
</SelectContent>
</Select>
</div>
);
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
// 사용
// 1⃣ filterConfig 정의
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'department',
label: '부서',
type: 'single',
options: departments.map(d => ({ value: d, label: d })),
allOptionLabel: '전체 부서',
},
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'draft', label: '작성중' },
{ value: 'confirmed', label: '확정' },
],
allOptionLabel: '전체 상태',
},
], [departments]);
// 2⃣ filterValues 상태 연결
const filterValues: FilterValues = useMemo(() => ({
department: filterDepartment,
status: filterStatus,
}), [filterDepartment, filterStatus]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'department') { setFilterDepartment(value as string); setCurrentPage(1); }
if (key === 'status') { setFilterStatus(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterDepartment('all');
setFilterStatus('all');
setCurrentPage(1);
}, []);
// 3⃣ tableHeaderActions에는 필터 외 액션만 (엑셀 등)
const tableHeaderActions = useMemo(() => (
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
엑셀
</Button>
), [handleExcelDownload]);
// 4⃣ 템플릿에 전달
<IntegratedListTemplateV2
tableHeaderActions={tableHeaderActionsNode}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="검색 필터"
tableHeaderActions={tableHeaderActions} // 엑셀 등 비필터 액션만
// ...
/>
```
| prop | 역할 | 필수 |
|------|------|:----:|
| `filterConfig` | 필터 필드 정의 (key, label, type, options) | ✅ |
| `filterValues` | 현재 필터 상태 | ✅ |
| `onFilterChange` | 필터 값 변경 핸들러 | ✅ |
| `onFilterReset` | 필터 초기화 핸들러 | ✅ |
| `filterTitle` | 모바일 바텀시트 타이틀 (기본: "검색 필터") | 권장 |
| `tableHeaderActions` | 필터 외 액션 (엑셀 버튼 등) | 필요 시 |
### 모바일 카드 (renderMobileCard)
```tsx

View File

@@ -0,0 +1,90 @@
# SAM ERP 프론트엔드 개발 가이드
> **대상**: SAM ERP 프론트엔드 신규/기존 개발자
> **최종 업데이트**: 2026-03-13
---
## 목차
| 문서 | 내용 |
|------|------|
| [00-overview.md](./00-overview.md) | 프로젝트 개요 및 기술 스택 (이 문서) |
| [01-project-structure.md](./01-project-structure.md) | 디렉토리 구조 및 파일 배치 규칙 |
| [02-routing-and-pages.md](./02-routing-and-pages.md) | 라우팅, 페이지 모드, 레이아웃 |
| [03-authentication.md](./03-authentication.md) | 인증 흐름, HttpOnly 쿠키, API 프록시 |
| [04-server-actions.md](./04-server-actions.md) | Server Action 패턴, API 통신 유틸리티 |
| [05-common-components.md](./05-common-components.md) | 공통 컴포넌트 (organisms, molecules, templates) |
| [06-ui-components.md](./06-ui-components.md) | UI 컴포넌트 카탈로그 |
| [07-hooks.md](./07-hooks.md) | 공통 Hooks |
| [08-utilities.md](./08-utilities.md) | 유틸리티 함수 (포맷터, URL 빌더, 인쇄 등) |
| [09-coding-conventions.md](./09-coding-conventions.md) | 코딩 컨벤션 및 필수 규칙 |
---
## 프로젝트 개요
```yaml
프로젝트: SAM ERP (통합 자원관리 시스템)
프론트엔드: Next.js 15 (App Router, TypeScript)
백엔드 API: PHP Laravel (sam-api)
특성: 인증 필수 폐쇄형 ERP (SEO 불필요, 크롤링 차단)
```
### 저장소 구조
```
sam_project/
├── sam-next/sma-next-project/sam-react-prod/ # Next.js 프론트엔드 (현재)
├── sam-api/sam-api/ # PHP Laravel 백엔드 API
├── sam-design/sam-design/ # React 디자인 시스템
└── sam-hotfix/sam-hotfix/ # E2E 테스트/핫픽스 관리
```
### 기술 스택
| 카테고리 | 기술 |
|----------|------|
| **프레임워크** | Next.js 15 (App Router, Turbopack) |
| **언어** | TypeScript (strict) |
| **스타일링** | Tailwind CSS |
| **UI 라이브러리** | Radix UI (shadcn/ui 기반) |
| **상태 관리** | Zustand |
| **폼 관리** | react-hook-form + Zod (신규 폼) |
| **차트** | Recharts |
| **국제화** | next-intl (ko, en, ja) |
| **토스트** | Sonner |
| **아이콘** | Lucide React |
| **날짜** | date-fns (한국어 locale) |
| **인증** | HttpOnly Cookie + API Proxy |
| **모바일** | Capacitor (하이브리드 앱) |
### 핵심 원칙
1. **모든 페이지는 Client Component** (`'use client'`) - 폐쇄형 ERP이므로 SEO 불필요, 서버 컴포넌트에서 쿠키 갱신 불가
2. **HttpOnly 쿠키 인증** - JavaScript에서 토큰 접근 불가, API Proxy 필수
3. **mode 쿼리파라미터** - `/new`, `/edit` 별도 경로 대신 `?mode=new`, `?mode=edit` 사용
4. **buildApiUrl 필수** - URL 직접 조립 금지
5. **컴포넌트 재사용** - 새 컴포넌트 전 기존 컴포넌트 검색 필수
### 개발 환경
```bash
# 로컬 개발 서버
npm run dev
# 타입 체크
npx tsc --noEmit
# 빌드 (로컬 확인용)
npm run build
```
### 브랜치 전략
| 브랜치 | 역할 |
|--------|------|
| `develop` | 평소 작업 브랜치 (자유롭게 커밋) |
| `stage` | QA/테스트 환경 |
| `main` | 배포용 (기능별 squash merge) |
| `feature/*` | 큰 기능/실험적 작업 |

View File

@@ -0,0 +1,112 @@
# 프로젝트 구조 및 파일 배치 규칙
## 디렉토리 구조
```
src/
├── app/ # Next.js App Router
│ ├── [locale]/ # i18n (ko, en, ja)
│ │ ├── (auth)/ # 인증 페이지 (로그인 등)
│ │ │ └── login/
│ │ ├── (protected)/ # 보호된 라우트 (인증 필수)
│ │ │ ├── accounting/ # 회계
│ │ │ ├── approval/ # 전자결재
│ │ │ ├── board/ # 게시판
│ │ │ ├── construction/ # 시공
│ │ │ ├── customer-center/ # 고객센터
│ │ │ ├── dashboard/ # 대시보드
│ │ │ ├── hr/ # 인사
│ │ │ ├── master-data/ # 기준정보
│ │ │ ├── material/ # 자재
│ │ │ ├── outbound/ # 출고
│ │ │ ├── production/ # 생산
│ │ │ ├── quality/ # 품질
│ │ │ ├── reports/ # 리포트
│ │ │ ├── sales/ # 영업
│ │ │ ├── settings/ # 설정
│ │ │ ├── [...slug]/ # catch-all (미구현 메뉴)
│ │ │ └── layout.tsx # 보호 레이아웃
│ │ ├── layout.tsx # 루트 레이아웃 (i18n)
│ │ └── page.tsx # / → /dashboard 리다이렉트
│ └── api/ # API Routes
│ ├── proxy/[...path]/ # HttpOnly 쿠키 프록시
│ ├── auth/ # 인증 엔드포인트
│ └── pdf/generate/ # PDF 생성
├── components/
│ ├── ui/ # 기본 UI 컴포넌트 (shadcn/ui 기반)
│ ├── molecules/ # 조합 컴포넌트 (FormField, StatusBadge 등)
│ ├── organisms/ # 페이지 구성 블록 (PageHeader, DataTable 등)
│ ├── templates/ # 페이지 템플릿 (IntegratedListTemplateV2 등)
│ ├── layout/ # 레이아웃 컴포넌트 (Sidebar, Header 등)
│ └── {domain}/ # 도메인별 컴포넌트
│ ├── accounting/
│ ├── hr/
│ ├── production/
│ ├── quality/
│ └── ...
├── hooks/ # 공통 Hooks
├── layouts/ # AuthenticatedLayout
├── lib/ # 유틸리티
│ ├── api/ # API 통신 유틸리티
│ ├── auth/ # 인증 유틸리티
│ ├── formatters.ts # 포맷팅 함수
│ ├── print-utils.ts # 인쇄 유틸리티
│ └── utils.ts # 기본 유틸리티 (cn 등)
├── stores/ # Zustand 스토어
├── i18n/ # 국제화 설정
├── types/ # 공통 타입 정의
└── styles/ # 글로벌 스타일
```
## 컴포넌트 계층
```
ui/ → 원자 컴포넌트 (Button, Input, Select ...)
molecules/ → 조합 컴포넌트 (FormField = Label + Input + Error)
organisms/ → 페이지 빌딩 블록 (PageHeader, DataTable, SearchFilter ...)
templates/ → 페이지 전체 템플릿 (IntegratedListTemplateV2)
{domain}/ → 도메인 전용 컴포넌트 (AccountingForm, QualityReport ...)
```
## 파일 배치 규칙
### 도메인 컴포넌트
```
src/components/{domain}/
├── {Feature}List.tsx # 목록 컴포넌트
├── {Feature}Detail.tsx # 상세/수정/등록 컴포넌트
├── {Feature}Modal.tsx # 모달 컴포넌트
└── actions.ts # Server Actions
```
### 페이지 파일
```
src/app/[locale]/(protected)/{domain}/{feature}/
├── page.tsx # 목록 + mode=new 분기
├── [id]/
│ └── page.tsx # 상세 + mode=edit 분기
└── actions.ts # (또는 components/{domain}/actions.ts)
```
### Server Actions 위치
- **우선**: `src/components/{domain}/actions.ts` (도메인별)
- **대안**: `src/app/[locale]/(protected)/{domain}/{feature}/actions.ts` (페이지별)
## 파일 네이밍
| 유형 | 네이밍 | 예시 |
|------|--------|------|
| 컴포넌트 | PascalCase | `VendorDetail.tsx` |
| 페이지 | `page.tsx` (Next.js 규칙) | `page.tsx` |
| Server Action | `actions.ts` | `actions.ts` |
| 유틸리티 | kebab-case | `query-params.ts` |
| Hook | camelCase + `use` 접두사 | `useColumnSettings.ts` |
| 타입 | `types.ts` 또는 인라인 | `types.ts` |
| 스키마 | `schema.ts` | `schema.ts` |
## 신규 파일 생성 전 체크리스트
- [ ] 유사 컴포넌트가 이미 있는지 `organisms/`, `molecules/` 확인
- [ ] 같은 도메인에 재사용 가능한 컴포넌트 확인
- [ ] dev/component-registry 페이지에서 검색
- [ ] 공통 UI 컴포넌트(`ui/`)로 해결 가능한지 확인

View File

@@ -0,0 +1,159 @@
# 라우팅 및 페이지 패턴
## 라우팅 구조
```
/[locale]/(auth)/login # 로그인
/[locale]/(protected)/dashboard # 대시보드
/[locale]/(protected)/{domain}/{feature} # 목록
/[locale]/(protected)/{domain}/{feature}?mode=new # 등록
/[locale]/(protected)/{domain}/{feature}/[id] # 상세(view)
/[locale]/(protected)/{domain}/{feature}/[id]?mode=edit # 수정
```
## 레이아웃 계층
```
Root Layout ([locale]/layout.tsx) - Server Component
├── i18n 설정 (NextIntlClientProvider)
├── 폰트 로드 (PretendardVariable)
├── Toaster (sonner)
└── Protected Layout ((protected)/layout.tsx) - Client Component
├── useAuthGuard() - 인증 보호
├── RootProvider - 전역 상태
├── ApiErrorProvider - 401 에러 처리
├── FCMProvider - 푸시 알림
├── PermissionGate - 권한 제어
└── AuthenticatedLayout
├── Sidebar - 메뉴
├── Header - 회사선택, 검색, 알림
├── HeaderFavoritesBar - 즐겨찾기
└── {children} - 페이지 컨텐츠
```
## 페이지 모드 패턴 (mode 쿼리파라미터)
### 규칙
- **별도 `/new`, `/edit` 경로 금지** → `?mode=new`, `?mode=edit` 사용
- 목록과 등록을 **같은 page.tsx에서 분기**
### 목록 + 등록 (page.tsx)
```tsx
'use client';
import { useSearchParams } from 'next/navigation';
export default function ItemsPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
// mode=new → 등록 폼
if (mode === 'new') {
return <ItemDetail mode="new" />;
}
// 기본 → 목록
return <ItemList />;
}
```
### 상세 + 수정 ([id]/page.tsx)
```tsx
'use client';
import { useParams, useSearchParams } from 'next/navigation';
export default function ItemDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const id = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <ItemDetail id={id} mode={mode} />;
}
```
### 네비게이션
```tsx
// 목록 → 등록
router.push('/master-data/items?mode=new');
// 목록 → 상세
router.push(`/master-data/items/${id}`);
// 상세 → 수정
router.push(`/master-data/items/${id}?mode=edit`);
// 수정 → 상세 (저장 후)
router.push(`/master-data/items/${id}`);
// → 목록으로
router.push('/master-data/items');
```
## 페이지 레이아웃 표준
### PageLayout 패딩 규칙
- `AuthenticatedLayout``<main>`에는 패딩 없음
- `PageLayout` 컴포넌트가 `p-3 md:p-6` 패딩 담당
- **page.tsx에서 패딩 wrapper 추가 금지** (이중 패딩 방지)
### 등록/수정/상세 페이지 헤더
```tsx
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">페이지 제목</h1>
<Button variant="link" className="text-muted-foreground"
onClick={() => router.push(listPath)}>
목록으로
</Button>
</div>
```
### 하단 Sticky 액션 바 (필수)
폼 페이지 하단에 sticky bar로 버튼 배치:
| 모드 | 좌측 | 우측 |
|------|------|------|
| 등록 (new) | `X 취소` | `💾 저장` |
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
| 수정 (edit) | `X 취소` | `💾 저장` |
```tsx
<div className="sticky bottom-0 bg-white border-t shadow-sm">
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
<Button variant="outline" onClick={() => router.push(listPath)}>
<X className="h-4 w-4 mr-1" />
취소
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: <Save className="h-4 w-4 mr-1" />}
저장
</Button>
</div>
</div>
```
## 테이블 표준
### 필수 컬럼 구조
**체크박스** → **번호(1부터)****데이터 컬럼****작업 컬럼**
```tsx
// 번호 계산 (페이지네이션 고려)
const globalIndex = (currentPage - 1) * pageSize + index + 1;
```
### 작업 버튼
- 체크박스 선택 시에만 표시
## i18n
```
지원 언어: ko (기본), en, ja
경로: /ko/..., /en/..., /ja/...
```

View File

@@ -0,0 +1,137 @@
# 인증 및 API 통신
## 인증 아키텍처
SAM ERP는 **HttpOnly Cookie 기반 인증**을 사용합니다. JavaScript에서 토큰을 직접 접근할 수 없으므로, 모든 인증 API 호출은 **Next.js API Proxy**를 통해 처리됩니다.
```
클라이언트 (브라우저)
├── Server Action 호출 (useEffect에서)
│ └── serverFetch() → 서버에서 쿠키 읽기 → 백엔드 API 호출
└── 프록시 API 호출 (fetch('/api/proxy/...'))
└── API Proxy Route → 서버에서 쿠키 읽기 → 백엔드 API 호출
```
## 쿠키 구조
| 쿠키명 | HttpOnly | 용도 | Max-Age |
|--------|:--------:|------|---------|
| `access_token` | O | API 인증 토큰 | 2시간 |
| `refresh_token` | O | 토큰 갱신용 | 7일 |
| `is_authenticated` | X | 클라이언트 인증 상태 확인 | 2시간 |
- `access_token`, `refresh_token`: HttpOnly → JavaScript 접근 불가 (XSS 방지)
- `is_authenticated`: non-HttpOnly → 클라이언트에서 인증 상태 확인 가능 (FCM 등)
- 프로덕션: `Secure` 플래그 활성화 (HTTPS만)
- `SameSite=Lax`: CSRF 방지
## 인증 흐름
### 로그인
```
1. 사용자 → /api/auth/login (POST)
2. 백엔드 → access_token + refresh_token 반환
3. API Route → Set-Cookie (HttpOnly) 설정
4. 클라이언트 → /(protected)/dashboard 리다이렉트
```
### API 요청 (Server Action)
```
1. 클라이언트 → Server Action 호출
2. serverFetch() → 쿠키에서 access_token 읽기
3. authenticatedFetch() → Authorization 헤더에 토큰 추가
4. 백엔드 API 호출 → 응답 반환
```
### 토큰 만료 시 (401 자동 갱신)
```
1. API 요청 → 401 응답 수신
2. authenticatedFetch() → refresh_token으로 갱신 요청
3. 새 토큰 수신 → 쿠키 업데이트
4. 원래 요청 재시도 → 성공
5. 갱신 실패 → 쿠키 삭제 → /login 리다이렉트
```
### 토큰 갱신 중복 방지
```typescript
// globalThis 레벨 캐싱 (5초)
// 여러 요청이 동시에 401을 받아도 refresh는 1회만 실행
// 진행 중인 refresh Promise를 공유하여 대기
```
## API 프록시 (`/api/proxy/[...path]`)
클라이언트에서 직접 백엔드 API를 호출해야 하는 경우 프록시 사용:
```typescript
// 클라이언트에서 프록시 호출
const response = await fetch('/api/proxy/item-master/init');
const data = await response.json();
```
프록시 내부 동작:
1. HttpOnly 쿠키에서 `access_token` 읽기
2. 백엔드 URL 구성 (`/api/proxy/*` → 백엔드 `/*`)
3. `Authorization: Bearer {token}` 헤더 추가
4. 요청 전달 → 응답 반환
5. 401 시 자동 토큰 갱신 후 재시도
6. 새 토큰 → Set-Cookie 헤더로 클라이언트에 전달
## 인증 보호
### Protected Layout
```tsx
// (protected)/layout.tsx
export default function ProtectedLayout({ children }) {
// 인증 가드 (뒤로가기 캐시 감지)
useAuthGuard();
return (
<RootProvider>
<ApiErrorProvider> {/* 401 에러 자동 처리 */}
<FCMProvider> {/* 푸시 알림 */}
<AuthenticatedLayout>
<PermissionGate> {/* 권한 기반 접근 제어 */}
{children}
</PermissionGate>
</AuthenticatedLayout>
</FCMProvider>
</ApiErrorProvider>
</RootProvider>
);
}
```
### 인증 상태 확인 (클라이언트)
```typescript
import { hasAuthToken } from '@/lib/api/auth-headers';
// is_authenticated 쿠키 확인 (non-HttpOnly)
if (hasAuthToken()) {
// 인증됨
}
```
## 로그아웃
완전한 로그아웃 절차:
1. Zustand 스토어 초기화 (useAuthStore, useMasterDataStore, useItemMasterStore)
2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
3. localStorage 사용자 데이터 삭제
4. FCM 토큰 해제 (Capacitor 환경)
5. 서버 로그아웃 API 호출
6. /login 리다이렉트
## 주의사항
- **Server Component에서 쿠키 수정 불가** → Client Component 사용 필수
- **`alert()`, `confirm()`, `prompt()` 사용 금지** → Radix UI Dialog 또는 `toast` 사용
- **API 직접 호출 금지** → 반드시 Server Action 또는 프록시 사용

View File

@@ -0,0 +1,245 @@
# Server Action 패턴
## 개요
모든 백엔드 API 호출은 Server Action을 통해 처리합니다. 공통 유틸리티를 사용하여 보일러플레이트를 제거하고 일관된 패턴을 유지합니다.
## 핵심 유틸리티
### buildApiUrl - URL 빌더 (필수)
```typescript
import { buildApiUrl } from '@/lib/api/query-params';
// 기본 사용
buildApiUrl('/api/v1/items')
// → "https://api.example.com/api/v1/items"
// 쿼리 파라미터
buildApiUrl('/api/v1/items', {
search: 'test',
status: 'active',
page: 1,
})
// → "https://api.example.com/api/v1/items?search=test&status=active&page=1"
// undefined/null/'' 자동 필터링
buildApiUrl('/api/v1/items', {
search: '', // 제외됨
status: undefined, // 제외됨
page: 1,
})
// → "https://api.example.com/api/v1/items?page=1"
// 동적 경로 + 파라미터
buildApiUrl(`/api/v1/items/${id}`, { with_details: true })
```
> **금지**: `new URLSearchParams()` 직접 사용, `${API_URL}` 직접 조립
### executeServerAction - 단건/목록 조회
```typescript
import { executeServerAction } from '@/lib/api/execute-server-action';
const result = await executeServerAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', { search: params.search }),
method: 'GET', // 기본값: GET
transform: (data) => ..., // snake_case → camelCase 변환
errorMessage: '조회에 실패했습니다.',
});
// 반환 타입
interface ActionResult<T> {
success: boolean;
data?: T;
error?: string;
fieldErrors?: Record<string, string[]>; // Laravel validation errors
__authError?: boolean; // 401 감지
}
```
### executePaginatedAction - 페이지네이션 조회
```typescript
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
const result = await executePaginatedAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformApiToFrontend, // 개별 아이템 변환 함수
errorMessage: '목록 조회에 실패했습니다.',
});
// 반환 타입
interface PaginatedActionResult<T> {
success: boolean;
data: T[]; // 변환된 아이템 배열
pagination: PaginationMeta; // 페이지네이션 정보
error?: string;
__authError?: boolean;
}
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
```
## Server Action 작성 패턴
### 표준 예시
```typescript
// src/components/{domain}/actions.ts
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ===== 1. API 원본 타입 (snake_case) =====
interface ItemApi {
id: number;
item_name: string;
item_code: string;
created_at: string;
}
// ===== 2. 프론트엔드 타입 (camelCase) =====
export interface Item {
id: string;
itemName: string;
itemCode: string;
createdAt: string;
}
// ===== 3. Transform 함수 =====
function transformItem(api: ItemApi): Item {
return {
id: String(api.id),
itemName: api.item_name,
itemCode: api.item_code,
createdAt: api.created_at,
};
}
// ===== 4. 목록 조회 (페이지네이션) =====
export async function getItems(params: {
search?: string;
status?: string;
page?: number;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformItem,
errorMessage: '품목 목록 조회에 실패했습니다.',
});
}
// ===== 5. 단건 조회 =====
export async function getItem(id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
transform: (data: { item: ItemApi }) => transformItem(data.item),
errorMessage: '품목 조회에 실패했습니다.',
});
}
// ===== 6. 생성 =====
export async function createItem(formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl('/api/v1/items'),
method: 'POST',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 등록에 실패했습니다.',
});
}
// ===== 7. 수정 =====
export async function updateItem(id: string, formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
method: 'PUT',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 수정에 실패했습니다.',
});
}
// ===== 8. 삭제 =====
export async function deleteItems(ids: string[]) {
return executeServerAction({
url: buildApiUrl('/api/v1/items/bulk-delete'),
method: 'POST',
body: { ids: ids.map(Number) },
errorMessage: '품목 삭제에 실패했습니다.',
});
}
```
## 컴포넌트에서 Server Action 호출
```tsx
'use client';
import { useEffect, useState } from 'react';
import { getItems, type Item } from '@/components/{domain}/actions';
export default function ItemList() {
const [data, setData] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getItems({ page: 1 })
.then(result => {
if (result.success) {
setData(result.data);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 ...</div>;
return <>{/* 렌더링 */}</>;
}
```
## 주의사항
### 'use server' 파일에서 타입 re-export 금지
```typescript
// ❌ 금지 - Next.js Turbopack 제한 (async 함수만 export 허용)
export type { Item } from './types';
export { type Item } from './types';
// ✅ 허용 - 인라인 타입 정의
export interface Item { ... }
export type Item = { ... };
// ✅ 허용 - 컴포넌트에서 원본 타입 파일 직접 import
// 컴포넌트에서: import type { Item } from './types';
```
### 데이터 변환 체인
```
Backend (snake_case) → safeResponseJson() → transform() → Frontend (camelCase)
```
- `safeResponseJson`: PHP 백엔드가 JSON 뒤에 경고 텍스트를 붙여 보내는 경우 방어
- `transform`: snake_case → camelCase 변환 (개발자 작성)

View File

@@ -0,0 +1,346 @@
# 공통 컴포넌트 가이드
## 컴포넌트 계층 요약
```
Templates → 페이지 전체 (IntegratedListTemplateV2)
Organisms → 페이지 블록 (PageHeader, DataTable, SearchFilter ...)
Molecules → 조합 단위 (FormField, StatusBadge, StandardDialog ...)
UI → 원자 단위 (Button, Input, Select ...)
```
---
## Templates
### IntegratedListTemplateV2
리스트 페이지를 위한 **올인원 템플릿**. 새 리스트 페이지 생성 시 이 템플릿 사용을 우선 검토합니다.
**경로**: `src/components/templates/IntegratedListTemplateV2.tsx`
**포함 기능**:
- PageLayout + PageHeader (아이콘/제목/설명)
- 검색 + 필터 + 날짜 선택 헤더
- 통계 카드 (StatCards)
- 테이블 + 컬럼 설정 + 페이지네이션
- 모바일 카드 자동 전환 (반응형)
- 체크박스 선택 (`Set<string>`)
**필수 적용 항목**:
1. 컬럼 설정 (`useColumnSettings` + `ColumnSettingsPopover`)
2. 모바일 카드 (`renderMobileCard`)
3. 체크박스 (`selectedItems: Set<string>`)
4. 테이블 내 필터 (`tableHeaderActions`)
**기본 사용법**:
```tsx
import IntegratedListTemplateV2 from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms';
const columns = [
{ key: 'itemName', label: '품목명', width: '200px' },
{ key: 'itemCode', label: '품목코드', width: '150px' },
{ key: 'status', label: '상태', width: '100px' },
];
export default function ItemListPage() {
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const { visibleColumns, allColumnsWithVisibility, columnWidths, setColumnWidth,
toggleColumnVisibility, resetSettings, hasHiddenColumns } =
useColumnSettings({ pageId: 'item-list', columns });
return (
<IntegratedListTemplateV2
title="품목 관리"
icon={Package}
description="품목 목록을 관리합니다"
// 검색
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="품목명 또는 코드로 검색"
// 테이블
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
data={items}
// 체크박스
selectedItems={selectedItems}
onToggleSelection={(id) => {
setSelectedItems(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
onToggleSelectAll={() => { /* 전체 선택/해제 */ }}
getItemId={(item) => item.id}
// 테이블 행
renderTableRow={(item, index, globalIndex, isSelected, onToggle) => (
<tr key={item.id} className={isSelected ? 'bg-blue-50' : ''}>
<td><Checkbox checked={isSelected} onCheckedChange={onToggle} /></td>
<td>{globalIndex}</td>
<td>{item.itemName}</td>
<td>{item.itemCode}</td>
</tr>
)}
// 모바일 카드 (반응형)
renderMobileCard={(item, index, globalIndex, isSelected, onToggle) => (
<MobileCard
title={item.itemName}
subtitle={item.itemCode}
isSelected={isSelected}
onToggleSelection={onToggle}
details={[
{ label: '상태', value: item.status },
]}
/>
)}
// 페이지네이션
pagination={{
currentPage: pagination.currentPage,
totalPages: pagination.lastPage,
totalItems: pagination.total,
itemsPerPage: pagination.perPage,
onPageChange: (page) => fetchData({ page }),
}}
isLoading={isLoading}
// 등록 버튼
createButton={{ label: '품목 등록', onClick: () => router.push('?mode=new') }}
/>
);
}
```
---
## Organisms
**경로**: `src/components/organisms/`
**import**: `import { PageHeader, DataTable, ... } from '@/components/organisms'`
### PageHeader
```tsx
<PageHeader
title="품목 관리"
description="품목 목록을 관리합니다"
icon={Package}
actions={<Button onClick={handleCreate}>등록</Button>}
/>
```
| Prop | 타입 | 설명 |
|------|------|------|
| `title` | string \| ReactNode | 페이지 제목 (필수) |
| `description?` | string | 부제목 |
| `icon?` | LucideIcon | 좌측 아이콘 |
| `actions?` | ReactNode | 우측 액션 버튼 |
### PageLayout
```tsx
<PageLayout maxWidth="full">
{children}
</PageLayout>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `maxWidth?` | "sm"\|"md"\|"lg"\|"xl"\|"2xl"\|"full" | "full" | 최대 너비 |
### StatCards
```tsx
<StatCards stats={[
{ label: '전체', value: 100, icon: Package },
{ label: '활성', value: 80, icon: CheckCircle, iconColor: 'text-green-500' },
{ label: '비활성', value: 20, icon: XCircle, iconColor: 'text-red-500' },
]} />
```
### SearchFilter
```tsx
<SearchFilter
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="검색어 입력"
extraActions={<DatePicker value={date} onChange={setDate} />}
/>
```
### DataTable
```tsx
<DataTable
columns={[
{ key: 'name', label: '이름', sortable: true },
{ key: 'status', label: '상태', type: 'badge' },
{ key: 'amount', label: '금액', type: 'currency', align: 'right' },
{ key: 'actions', label: '', type: 'custom',
render: (_, row) => <Button size="sm">수정</Button> },
]}
data={items}
keyField="id"
onRowClick={(row) => router.push(`/items/${row.id}`)}
pagination={{ currentPage, totalPages, onPageChange }}
/>
```
**Column type 종류**: `text`, `number`, `currency`, `date`, `datetime`, `status`, `badge`, `icon`, `actions`, `custom`
### SearchableSelectionModal
검색+선택 팝업이 필요할 때 사용. **직접 Dialog 조합 금지**.
```tsx
<SearchableSelectionModal<Vendor>
open={isOpen}
onOpenChange={setIsOpen}
title="거래처 검색"
fetchData={async (query) => {
const result = await searchVendors({ search: query });
return result.success ? result.data : [];
}}
keyExtractor={(vendor) => vendor.id}
mode="single"
onSelect={(vendor) => handleVendorSelect(vendor)}
searchPlaceholder="거래처명으로 검색"
renderItem={(vendor, isSelected) => (
<div className={cn('p-3', isSelected && 'bg-blue-50')}>
<div className="font-medium">{vendor.name}</div>
<div className="text-sm text-muted-foreground">{vendor.code}</div>
</div>
)}
/>
```
| Prop | 필수 | 설명 |
|------|:---:|------|
| `open` | O | 모달 열기 상태 |
| `onOpenChange` | O | 상태 변경 |
| `title` | O | 모달 제목 |
| `fetchData` | O | `(query: string) => Promise<T[]>` |
| `keyExtractor` | O | `(item: T) => string` |
| `mode` | O | `'single'` \| `'multiple'` |
| `onSelect` | O | 선택 콜백 |
| `renderItem` | O | 아이템 렌더링 |
| `searchMode?` | | `'debounce'`(기본) \| `'enter'` |
| `loadOnOpen?` | | 열릴 때 자동 로드 |
| `listWrapper?` | | 리스트 래퍼 (테이블 구조 등) |
### MobileCard / InfoField
```tsx
<MobileCard
title="품목A"
subtitle="P-001"
isSelected={isSelected}
onToggleSelection={onToggle}
details={[
{ label: '규격', value: '100x200' },
{ label: '단가', value: '10,000원' },
]}
onClick={() => router.push(`/items/${item.id}`)}
/>
```
### EmptyState / TableEmptyState
```tsx
<EmptyState message="데이터가 없습니다" />
<TableEmptyState colSpan={5} message="검색 결과가 없습니다" />
```
---
## Molecules
**경로**: `src/components/molecules/`
### FormField (신규 폼 필수)
`Label + Input + Error` 수동 조합 대신 사용.
```tsx
import { FormField } from '@/components/molecules/FormField';
<FormField
label="회사명"
required
type="text"
value={formData.companyName}
onChange={(value) => handleChange('companyName', value)}
placeholder="회사명을 입력하세요"
disabled={mode === 'view'}
error={errors.companyName}
/>
```
**지원 type**: `text`, `number`, `date`, `select`, `textarea`, `custom`, `password`, `phone`, `businessNumber`, `personalNumber`, `currency`, `quantity`
**FormField로 대체하지 않는 경우**:
- Select, DatePicker, ImageUpload 등 특수 컴포넌트
- 주소 검색(버튼+입력) 등 복합 레이아웃
- 편집/읽기 모드가 다른 커스텀 인터랙션
### StatusBadge
```tsx
import { StatusBadge } from '@/components/molecules/StatusBadge';
<StatusBadge label="승인" variant="success" />
<StatusBadge label="대기" variant="warning" showDot />
<StatusBadge label="반려" variant="danger" />
```
**variant**: `default`, `success`, `warning`, `danger`, `info`, `secondary`, `outline`
### ColumnSettingsPopover
`useColumnSettings` hook과 함께 사용:
```tsx
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
```
### StandardDialog
```tsx
<StandardDialog
open={isOpen}
onOpenChange={setIsOpen}
title="확인"
description="정말 삭제하시겠습니까?"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setIsOpen(false)}>취소</Button>
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
</>
}
>
<p> 작업은 되돌릴 없습니다.</p>
</StandardDialog>
```
**size**: `sm`, `md`, `lg`, `xl`, `full`

View File

@@ -0,0 +1,176 @@
# UI 컴포넌트 카탈로그
**경로**: `src/components/ui/`
**기반**: shadcn/ui (Radix UI + Tailwind CSS)
---
## 입력 컴포넌트
### 기본 입력
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Input` | `input.tsx` | 텍스트 입력 |
| `Textarea` | `textarea.tsx` | 여러 줄 텍스트 |
| `Checkbox` | `checkbox.tsx` | 체크박스 |
| `RadioGroup` | `radio-group.tsx` | 라디오 버튼 |
| `Switch` | `switch.tsx` | 토글 스위치 |
| `Slider` | `slider.tsx` | 슬라이더 |
| `Select` | `select.tsx` | 셀렉트 (Radix UI) |
### 특화 입력
| 컴포넌트 | 파일 | 용도 | 특징 |
|----------|------|------|------|
| `DatePicker` | `date-picker.tsx` | 날짜 선택 | 한글 locale, 주말/휴일 색상, 연/월 선택, "오늘" 버튼 |
| `DateRangePicker` | `date-range-picker.tsx` | 기간 선택 | 시작~종료 날짜 |
| `DateTimePicker` | `date-time-picker.tsx` | 날짜+시간 | |
| `TimePicker` | `time-picker.tsx` | 시간만 | |
| `PhoneInput` | `phone-input.tsx` | 전화번호 | 자동 하이픈 (010-1234-5678) |
| `BusinessNumberInput` | `business-number-input.tsx` | 사업자번호 | 자동 포맷 (000-00-00000) |
| `PersonalNumberInput` | `personal-number-input.tsx` | 주민번호 | 마스킹 가능 |
| `CardNumberInput` | `card-number-input.tsx` | 카드번호 | 4자리 구분 |
| `AccountNumberInput` | `account-number-input.tsx` | 계좌번호 | |
| `NumberInput` | `number-input.tsx` | 숫자 | |
| `CurrencyInput` | `currency-input.tsx` | 금액 | 천단위 콤마, ₩ 접두사 |
| `QuantityInput` | `quantity-input.tsx` | 수량 | +/- 버튼 |
| `FileInput` | `file-input.tsx` | 파일 | |
| `FileDropzone` | `file-dropzone.tsx` | 파일 드래그 앤 드롭 | |
| `ImageUpload` | `image-upload.tsx` | 이미지 업로드 | 미리보기 |
### 검색/선택
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `SearchableSelect` | `searchable-select.tsx` | 검색 가능 셀렉트 |
| `MultiSelectCombobox` | `multi-select-combobox.tsx` | 다중 선택 콤보박스 |
| `Command` | `command.tsx` | 검색/필터 커맨드 팔레트 |
---
## 피드백 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Button` | `button.tsx` | 버튼 (variant: default, destructive, outline, secondary, ghost, link) |
| `Badge` | `badge.tsx` | 뱃지 |
| `Alert` | `alert.tsx` | 알림 |
| `AlertDialog` | `alert-dialog.tsx` | 알림 다이얼로그 |
| `ConfirmDialog` | `confirm-dialog.tsx` | 확인 다이얼로그 |
| `ErrorCard` | `error-card.tsx` | 에러 카드 |
| `ErrorMessage` | `error-message.tsx` | 에러 메시지 |
| `LoadingSpinner` | `loading-spinner.tsx` | 로딩 스피너 |
| `Skeleton` | `skeleton.tsx` | 스켈레톤 (로딩 플레이스홀더) |
| `toast` | `sonner` 라이브러리 | 토스트 알림 |
---
## 레이아웃 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Card` | `card.tsx` | 카드 (CardHeader, CardContent, CardFooter) |
| `Dialog` | `dialog.tsx` | 다이얼로그 (모달) |
| `Drawer` | `drawer.tsx` | 드로어 (하단/측면 패널) |
| `Popover` | `popover.tsx` | 팝오버 |
| `Sheet` | `sheet.tsx` | 시트 (측면 패널) |
| `Accordion` | `accordion.tsx` | 아코디언 (접기/펼치기) |
| `Tabs` | `tabs.tsx` | 탭 |
| `Table` | `table.tsx` | 테이블 (Table, TableHeader, TableBody, TableRow, TableCell) |
---
## 기타 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Label` | `label.tsx` | 라벨 |
| `Separator` | `separator.tsx` | 구분선 |
| `ScrollArea` | `scroll-area.tsx` | 스크롤 영역 |
| `Tooltip` | `tooltip.tsx` | 툴팁 |
| `Progress` | `progress.tsx` | 진행률 바 |
| `FileList` | `file-list.tsx` | 파일 목록 표시 |
| `ChartWrapper` | `chart-wrapper.tsx` | 차트 래퍼 (Recharts) |
| `EmptyState` | `empty-state.tsx` | 빈 상태 표시 |
---
## DatePicker 사용법
프로젝트 전체에서 `<input type="date">` 대신 사용.
```tsx
import { DatePicker } from '@/components/ui/date-picker';
// 기본
<DatePicker
value={date} // "yyyy-MM-dd" 문자열
onChange={(date) => setDate(date)}
/>
// 옵션
<DatePicker
value={date}
onChange={setDate}
placeholder="날짜 선택"
size="sm" // "default" | "sm" | "lg"
disabled={!isEditMode}
minDate={new Date('2024-01-01')}
maxDate={new Date()}
/>
```
**Props**:
- `value`: `string` (yyyy-MM-dd 형식)
- `onChange`: `(date: string) => void`
- `size?`: `"default"` | `"sm"` | `"lg"`
- `disabled?`, `placeholder?`, `className?`
- `minDate?`, `maxDate?`: `Date` 타입 (**문자열 아님**)
---
## Radix UI Select 주의사항
빈 값('')으로 마운트 후 value 변경이 반영 안 되는 버그:
```tsx
// ✅ key prop으로 강제 리마운트
<Select
key={`${fieldKey}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
>
{/* options */}
</Select>
```
---
## 팝업 정책
```
❌ 금지: alert(), confirm(), prompt()
✅ 사용: AlertDialog, ConfirmDialog, toast (sonner)
```
```tsx
// 토스트
import { toast } from 'sonner';
toast.success('저장되었습니다');
toast.error('오류가 발생했습니다');
// 확인 다이얼로그
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
<AlertDialogDescription>정말 삭제하시겠습니까?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
```

View File

@@ -0,0 +1,186 @@
# 공통 Hooks
**경로**: `src/hooks/`
---
## 리스트 페이지 관련
### useColumnSettings
테이블 컬럼 표시/숨기기 및 너비 관리. `IntegratedListTemplateV2`와 함께 사용.
```tsx
import { useColumnSettings } from '@/hooks/useColumnSettings';
const columns = [
{ key: 'name', label: '이름', width: '200px' },
{ key: 'code', label: '코드', width: '150px' },
{ key: 'status', label: '상태', width: '100px' },
];
const {
visibleColumns, // 현재 표시되는 컬럼
allColumnsWithVisibility, // 전체 컬럼 (visibility/locked 포함)
columnWidths, // 컬럼 너비 맵
setColumnWidth, // 컬럼 너비 변경
toggleColumnVisibility, // 컬럼 표시/숨기기 토글
resetSettings, // 초기화
hasHiddenColumns, // 숨겨진 컬럼 존재 여부
} = useColumnSettings({
pageId: 'item-list', // Zustand 저장 키 (고유)
columns,
alwaysVisibleKeys: ['name'], // 항상 표시되는 컬럼 (숨기기 불가)
});
```
### useListHandlers
리스트 페이지 검색, 필터, 페이지네이션 핸들러 통합.
```tsx
import { useListHandlers } from '@/hooks/useListHandlers';
const { search, setSearch, pagination, handlePageChange, handleSearch } = useListHandlers({
initialSearch: '',
fetchData: getItems,
});
```
### useCRUDHandlers
생성, 수정, 삭제 핸들러 통합.
```tsx
import { useCRUDHandlers } from '@/hooks/useCRUDHandlers';
const { handleCreate, handleUpdate, handleDelete, isSubmitting } = useCRUDHandlers({
createFn: createItem,
updateFn: updateItem,
deleteFn: deleteItems,
onSuccess: () => fetchData(),
});
```
### useDeleteDialog
삭제 확인 다이얼로그 상태 관리.
```tsx
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
const { isOpen, openDialog, closeDialog, confirmDelete, targetId } = useDeleteDialog({
onConfirm: async (id) => {
await deleteItem(id);
fetchData();
},
});
```
---
## 상세 페이지 관련
### useDetailPageState
상세/수정/등록 페이지의 모드 및 상태 관리.
```tsx
import { useDetailPageState } from '@/hooks/useDetailPageState';
const { mode, isEditMode, isNewMode, isViewMode } = useDetailPageState();
```
### useDetailData
상세 데이터 비동기 로드.
```tsx
import { useDetailData } from '@/hooks/useDetailData';
const { data, isLoading, error, refetch } = useDetailData({
id: params.id,
fetchFn: getItem,
});
```
---
## 데이터 관련
### useCommonCodes
공통 코드 조회 (상태, 분류 등).
```tsx
import { useCommonCodes } from '@/hooks/useCommonCodes';
const { codes, isLoading } = useCommonCodes('item_status');
// codes: [{ id: 'active', name: '활성' }, { id: 'inactive', name: '비활성' }]
```
### useClientList
거래처 목록 조회.
```tsx
import { useClientList } from '@/hooks/useClientList';
const { clients, isLoading } = useClientList();
```
### useItemList
품목 목록 조회.
```tsx
import { useItemList } from '@/hooks/useItemList';
const { items, isLoading } = useItemList();
```
---
## 유틸리티 관련
### useDateRange
날짜 범위 상태 관리.
```tsx
import { useDateRange } from '@/hooks/useDateRange';
const { startDate, endDate, setStartDate, setEndDate, reset } = useDateRange({
defaultStart: '2024-01-01',
defaultEnd: '2024-12-31',
});
```
### usePermission
권한 기반 접근 제어.
```tsx
import { usePermission } from '@/hooks/usePermission';
const { canRead, canWrite, canDelete } = usePermission('item_master');
if (!canWrite) {
return <div>수정 권한이 없습니다.</div>;
}
```
### useDaumPostcode
다음 우편번호 API 연동.
```tsx
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
const { openPostcode } = useDaumPostcode({
onComplete: (data) => {
setAddress(data.address);
setZipCode(data.zonecode);
},
});
```

View File

@@ -0,0 +1,175 @@
# 유틸리티 함수
---
## cn - 클래스명 병합
```typescript
import { cn } from '@/lib/utils';
// clsx + tailwind-merge
<div className={cn('p-4 bg-white', isActive && 'bg-blue-50', className)} />
```
## safeJsonParse - 안전한 JSON 파싱
```typescript
import { safeJsonParse } from '@/lib/utils';
const data = safeJsonParse<Config>(localStorage.getItem('config'), defaultConfig);
// 파싱 실패 시 fallback 반환
```
---
## 포맷팅 함수
**경로**: `src/lib/formatters.ts`
### 전화번호
```typescript
import { formatPhoneNumber, parsePhoneNumber } from '@/lib/formatters';
formatPhoneNumber('01012345678') // → "010-1234-5678"
parsePhoneNumber('010-1234-5678') // → "01012345678" (숫자만)
```
### 사업자번호
```typescript
import { formatBusinessNumber, validateBusinessNumber } from '@/lib/formatters';
formatBusinessNumber('1234567890') // → "123-45-67890"
validateBusinessNumber('1234567890') // → true/false (체크섬 검증)
```
### 주민번호
```typescript
import { formatPersonalNumber, formatPersonalNumberMasked } from '@/lib/formatters';
formatPersonalNumber('9001011234567') // → "900101-1234567"
formatPersonalNumberMasked('9001011234567') // → "900101-*******"
```
### 카드/계좌번호
```typescript
import { formatCardNumber, formatAccountNumber } from '@/lib/formatters';
formatCardNumber('1234567890123456') // → "1234-5678-9012-3456"
formatAccountNumber('12345678901234') // → "1234-5678-9012-34"
```
### 숫자/금액
```typescript
import { formatNumber, parseNumber, extractDigits } from '@/lib/formatters';
formatNumber(1234567) // → "1,234,567"
parseNumber('1,234,567') // → 1234567
extractDigits('abc-123-def') // → "123"
```
---
## URL 빌더
**경로**: `src/lib/api/query-params.ts`
```typescript
import { buildApiUrl, buildQueryParams } from '@/lib/api/query-params';
// API URL 생성 (undefined/null/'' 자동 필터링)
const url = buildApiUrl('/api/v1/items', {
search: 'test',
status: undefined, // 제외
page: 1,
});
// 쿼리 파라미터만 생성
const params = buildQueryParams({ search: 'test', page: 1 });
// → URLSearchParams 객체
```
---
## 인쇄 유틸리티
**경로**: `src/lib/print-utils.ts`
```typescript
import { printElement, printArea } from '@/lib/print-utils';
// 특정 요소 인쇄
printElement(document.getElementById('invoice'));
// .print-area 클래스 영역 인쇄
printArea({ title: '견적서' });
// 옵션
printElement('#invoice', {
title: '견적서', // 브라우저 탭 제목
styles: customCSS, // 추가 CSS
closeAfterPrint: true, // 인쇄 후 창 닫기
});
```
**HTML에서 사용**:
```tsx
<div className="print-area">
{/* 인쇄될 영역 */}
</div>
```
---
## 인증 헤더
**경로**: `src/lib/api/auth-headers.ts`
```typescript
import { getAuthHeaders, getMultipartHeaders, hasAuthToken } from '@/lib/api/auth-headers';
// JSON 요청 헤더 (프록시 사용 시)
const headers = getAuthHeaders();
// → { 'Content-Type': 'application/json', 'Accept': 'application/json' }
// Multipart FormData 헤더
const headers = getMultipartHeaders();
// → { 'Accept': 'application/json' }
// 인증 상태 확인 (클라이언트)
if (hasAuthToken()) { /* 인증됨 */ }
```
---
## 에러 처리
**경로**: `src/lib/api/errors.ts`
```typescript
import { createErrorResponse, isApiError, isAuthError } from '@/lib/api/errors';
// 에러 응답 생성
const error = createErrorResponse(404, '데이터를 찾을 수 없습니다');
// 에러 타입 확인
if (isApiError(response)) { /* API 에러 */ }
if (isAuthError(response)) { /* 인증 에러 (401) */ }
```
---
## localStorage 접근 (Next.js 호환)
```typescript
// ✅ Next.js Pattern (SSR 안전)
const [data, setData] = useState(() => {
if (typeof window === 'undefined') return defaultValue;
const saved = localStorage.getItem('key');
return saved ? JSON.parse(saved) : defaultValue;
});
```

View File

@@ -0,0 +1,205 @@
# 코딩 컨벤션 및 필수 규칙
---
## Client Component 필수
모든 페이지는 `'use client'` 선언 필수. Server Component 사용 금지.
```tsx
// ✅ 올바른 패턴
'use client';
export default function Page() { ... }
// ❌ 금지
export default async function Page() { ... }
```
**이유**: 폐쇄형 ERP (SEO 불필요), Server Component에서 쿠키 수정(토큰 갱신) 불가
## 데이터 로딩 패턴
```tsx
'use client';
import { useEffect, useState } from 'react';
import { getData } from '@/components/.../actions';
export default function Page() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getData()
.then(result => {
if (result.success) setData(result.data);
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 ...</div>;
return <Component data={data} />;
}
```
---
## buildApiUrl 필수 사용
```tsx
// ✅ 필수
import { buildApiUrl } from '@/lib/api/query-params';
const url = buildApiUrl('/api/v1/items', { search, page });
// ❌ 금지
const params = new URLSearchParams();
params.set('search', value);
const url = `${API_URL}/api/v1/items?${params.toString()}`;
```
---
## 컴포넌트 재사용 우선
새 컴포넌트 작성 전 확인 순서:
1. `src/components/organisms/index.ts` export 목록
2. `src/components/molecules/` 내 공통 컴포넌트
3. `src/components/ui/` 내 UI 컴포넌트
4. dev/component-registry 페이지 검색
5. 동일 도메인 기존 컴포넌트
---
## FormField 사용 (신규 폼)
```tsx
// ✅ 신규 폼 - FormField 사용
<FormField label="회사명" value={v} onChange={handleChange} />
// ❌ 신규 폼에서 수동 조합 금지
<div className="space-y-2">
<Label>회사명</Label>
<Input value={v} onChange={handleChange} />
</div>
```
**기존 폼**: 건드리지 않음 (정상 작동 중이면 마이그레이션 불필요)
---
## Zod 스키마 검증 (신규 폼)
```tsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. 스키마 정의
const formSchema = z.object({
itemName: z.string().min(1, '품목명을 입력하세요'),
quantity: z.number().min(1, '1 이상 입력하세요'),
status: z.enum(['active', 'inactive']),
memo: z.string().optional(),
});
// 2. 타입 추출 (별도 interface 정의 불필요)
type FormData = z.infer<typeof formSchema>;
// 3. useForm에 연결
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { itemName: '', quantity: 1, status: 'active' },
});
```
**규칙**:
- 에러 메시지 한글 작성
- 스키마 위치: 컴포넌트 파일 상단 또는 `schema.ts`
- `z.infer` 사용, 별도 `interface` 중복 정의 금지
---
## 팝업 정책
```
❌ 금지: alert(), confirm(), prompt()
✅ 사용: Radix UI Dialog/AlertDialog, toast (sonner)
```
---
## 검색 모달 표준
```
❌ 금지: Dialog + Input + 리스트 직접 조합
✅ 사용: SearchableSelectionModal<T>
```
---
## 리스트 페이지 필수 항목
`IntegratedListTemplateV2` 사용 시:
- [ ] `useColumnSettings` + `ColumnSettingsPopover` 적용
- [ ] `renderMobileCard` (모바일 카드) 구현
- [ ] `selectedItems: Set<string>` (체크박스) 구현
- [ ] `tableHeaderActions` (테이블 내 필터) 필요 시 구현
---
## 테이블 rowSpan/colSpan (문서/보고서)
**반드시 구조 분석 → 코딩 순서**:
1. **플랫 인덱스 맵**: 실제 렌더링 행 수 기준으로 인덱스 산정
2. **병합 범위 표기**: span은 그룹 첫 행에만
3. **Coverage Map 패턴**:
```typescript
function buildCoverageMap(items, spanKey) {
const map = {};
const covered = new Set();
items.forEach((item, idx) => {
const span = item[spanKey];
if (span && span > 1) {
map[idx] = span;
for (let i = idx + 1; i < idx + span; i++) covered.add(i);
}
});
return { map, covered };
}
// map에 있으면 → <td rowSpan={span}>
// covered에 있으면 → skip (렌더링 안 함)
// 둘 다 아니면 → 일반 <td>
```
---
## Git 규칙
- **develop**: 평소 작업 (자유롭게 커밋)
- **main**: 기능별 squash merge만 (직접 push 금지)
- **커밋 메시지**: `[타입]: 작업내용` (feat, fix, chore, refactor 등)
- **`snapshot.txt`, `.DS_Store`**: 항상 제외
---
## 빌드 정책
- 개발자가 직접 빌드 확인
- TypeScript strict 모드 사용
- ESLint: 빌드 시 무시 (CI에서 별도 처리)
---
## 신규 페이지 생성 체크리스트
- [ ] `'use client'` 선언
- [ ] `?mode=new/edit` 쿼리파라미터 패턴 사용 (`/new`, `/edit` 경로 금지)
- [ ] Server Action에서 `buildApiUrl()` 사용
- [ ] 기존 컴포넌트 재사용 확인 (organisms, molecules 검색)
- [ ] 리스트 페이지: `IntegratedListTemplateV2` 사용 검토
- [ ] 폼 페이지: FormField, Zod 스키마 사용 (신규)
- [ ] 검색 모달: `SearchableSelectionModal` 사용
- [ ] 하단 sticky 액션 바 구현
- [ ] 모바일 반응형 대응
- [ ] 타입 체크 (`npx tsc --noEmit`)

View File

@@ -0,0 +1,17 @@
import { Suspense } from 'react';
import { CorporateVehicleList } from '@/components/vehicle/CorporateVehicles';
import { ListPageSkeleton } from '@/components/ui/skeleton';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '법인차량관리',
description: '법인/렌트/리스 차량을 관리합니다',
};
export default function CorporateVehiclesPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={4} />}>
<CorporateVehicleList />
</Suspense>
);
}

View File

@@ -0,0 +1,17 @@
import { Suspense } from 'react';
import { VehicleLogList } from '@/components/vehicle/VehicleLogs';
import { ListPageSkeleton } from '@/components/ui/skeleton';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '차량일지',
description: '차량 운행기록을 관리합니다',
};
export default function VehicleLogsPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={5} />}>
<VehicleLogList />
</Suspense>
);
}

View File

@@ -0,0 +1,17 @@
import { Suspense } from 'react';
import { VehicleMaintenanceList } from '@/components/vehicle/VehicleMaintenance';
import { ListPageSkeleton } from '@/components/ui/skeleton';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '정비이력',
description: '차량 정비 및 유지비 이력을 관리합니다',
};
export default function VehicleMaintenancePage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={4} />}>
<VehicleMaintenanceList />
</Suspense>
);
}

View File

@@ -0,0 +1,441 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { Loader2 } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
type CorporateVehicle,
type VehicleFormData,
type OwnershipType,
EMPTY_VEHICLE_FORM,
VEHICLE_TYPES,
} from '../types';
import {
createCorporateVehicle,
updateCorporateVehicle,
deleteCorporateVehicle,
} from './actions';
interface VehicleFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
vehicle?: CorporateVehicle | null;
onSuccess: () => void;
}
export function VehicleFormDialog({
open,
onOpenChange,
mode,
vehicle,
onSuccess,
}: VehicleFormDialogProps) {
const [form, setForm] = useState<VehicleFormData>(EMPTY_VEHICLE_FORM);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
const isCorporate = form.ownershipType !== 'rent' && form.ownershipType !== 'lease';
useEffect(() => {
if (!open) return;
if (isEdit && vehicle) {
setForm({
plateNumber: vehicle.plateNumber,
vehicleType: vehicle.vehicleType || '',
ownershipType: vehicle.ownershipType || '',
model: vehicle.model,
year: vehicle.year ? String(vehicle.year) : '',
purchaseDate: vehicle.purchaseDate || '',
contractDate: vehicle.contractDate || '',
rentCompany: vehicle.rentCompany || '',
rentPeriod: vehicle.rentPeriod || '',
purchasePrice: vehicle.purchasePrice ? String(vehicle.purchasePrice) : '',
monthlyRent: vehicle.monthlyRent ? String(vehicle.monthlyRent) : '',
monthlyRentTax: vehicle.monthlyRentTax ? String(vehicle.monthlyRentTax) : '',
rentCompanyTel: vehicle.rentCompanyTel || '',
agreedMileage: vehicle.agreedMileage || '',
vehiclePrice: vehicle.vehiclePrice ? String(vehicle.vehiclePrice) : '',
residualValue: vehicle.residualValue ? String(vehicle.residualValue) : '',
deposit: vehicle.deposit ? String(vehicle.deposit) : '',
mileage: vehicle.mileage ? String(vehicle.mileage) : '',
insuranceCompany: vehicle.insuranceCompany || '',
insuranceCompanyTel: vehicle.insuranceCompanyTel || '',
driver: vehicle.driver || '',
status: vehicle.status || '',
memo: vehicle.memo || '',
});
} else {
setForm(EMPTY_VEHICLE_FORM);
}
}, [open, isEdit, vehicle]);
const updateField = useCallback(
<K extends keyof VehicleFormData>(key: K, value: VehicleFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleSubmit = async () => {
if (!form.plateNumber || !form.ownershipType || !form.model) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = isEdit && vehicle
? await updateCorporateVehicle(vehicle.id, form)
: await createCorporateVehicle(form);
if (result.success) {
toast.success(isEdit ? '차량이 수정되었습니다.' : '차량이 등록되었습니다.');
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!vehicle) return;
setIsDeleting(true);
try {
const result = await deleteCorporateVehicle(vehicle.id);
if (result.success) {
toast.success('차량이 삭제되었습니다.');
setDeleteOpen(false);
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? '차량 수정' : '차량 등록'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Row 1: 차량번호, 종류, 구분 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.plateNumber}
onChange={(e) => updateField('plateNumber', e.target.value)}
placeholder="12가 3456"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
key={`vt-${form.vehicleType}`}
value={form.vehicleType}
onValueChange={(v) => updateField('vehicleType', v as VehicleFormData['vehicleType'])}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{VEHICLE_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`ot-${form.ownershipType}`}
value={form.ownershipType}
onValueChange={(v) => updateField('ownershipType', v as OwnershipType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="corporate"></SelectItem>
<SelectItem value="rent"></SelectItem>
<SelectItem value="lease"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 모델 */}
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.model}
onChange={(e) => updateField('model', e.target.value)}
placeholder="차량 모델명"
/>
</div>
{/* Row 3: 연식, 취득일/계약일자 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.year}
onChange={(e) => updateField('year', e.target.value)}
placeholder="2026"
/>
</div>
<div className="space-y-1.5">
<Label>{isCorporate ? '취득일' : '계약일자'}</Label>
{isCorporate ? (
<DatePicker
value={form.purchaseDate}
onChange={(v) => updateField('purchaseDate', v)}
placeholder="연도. 월. 일."
/>
) : (
<DatePicker
value={form.contractDate}
onChange={(v) => updateField('contractDate', v)}
placeholder="연도. 월. 일."
/>
)}
</div>
</div>
{/* Row 4: 구매처/렌트회사명, 계약기간/렌트기간 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{isCorporate ? '구매처' : '렌트회사명'}</Label>
<Input
value={form.rentCompany}
onChange={(e) => updateField('rentCompany', e.target.value)}
placeholder={isCorporate ? '회사명' : '렌트회사명'}
/>
</div>
<div className="space-y-1.5">
<Label>{isCorporate ? '계약기간' : '렌트기간'}</Label>
<Input
value={form.rentPeriod}
onChange={(e) => updateField('rentPeriod', e.target.value)}
placeholder="예: 36개월"
/>
</div>
</div>
{/* Row 5: 취득가(공급가)/월렌트료(공급가), 세액 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{isCorporate ? '취득가 (공급가)' : '월 렌트료 (공급가)'}</Label>
{isCorporate ? (
<Input
type="number"
value={form.purchasePrice}
onChange={(e) => updateField('purchasePrice', e.target.value)}
placeholder="0"
/>
) : (
<Input
type="number"
value={form.monthlyRent}
onChange={(e) => updateField('monthlyRent', e.target.value)}
placeholder="0"
/>
)}
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.monthlyRentTax}
onChange={(e) => updateField('monthlyRentTax', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 6: 회사 연락처, 약정운행거리 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> </Label>
<Input
value={form.rentCompanyTel}
onChange={(e) => updateField('rentCompanyTel', e.target.value)}
placeholder="연락처"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.agreedMileage}
onChange={(e) => updateField('agreedMileage', e.target.value)}
placeholder="km"
/>
</div>
</div>
{/* Row 7: 차량가격, 추정잔존가액 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.vehiclePrice}
onChange={(e) => updateField('vehiclePrice', e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.residualValue}
onChange={(e) => updateField('residualValue', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 8: 보증금, 최초 주행거리 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.deposit}
onChange={(e) => updateField('deposit', e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label> (km)</Label>
<Input
type="number"
value={form.mileage}
onChange={(e) => updateField('mileage', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 9: 보험사명, 보험사 연락처 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.insuranceCompany}
onChange={(e) => updateField('insuranceCompany', e.target.value)}
placeholder="보험사명"
/>
</div>
<div className="space-y-1.5">
<Label> </Label>
<Input
value={form.insuranceCompanyTel}
onChange={(e) => updateField('insuranceCompanyTel', e.target.value)}
placeholder="연락처"
/>
</div>
</div>
{/* Row 10: 운전자, 상태 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.driver}
onChange={(e) => updateField('driver', e.target.value)}
placeholder="운전자명"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
key={`st-${form.status}`}
value={form.status}
onValueChange={(v) => updateField('status', v as VehicleFormData['status'])}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="maintenance"></SelectItem>
<SelectItem value="disposed"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Row 11: 메모 */}
<div className="space-y-1.5">
<Label></Label>
<Textarea
value={form.memo}
onChange={(e) => updateField('memo', e.target.value)}
placeholder="메모"
rows={2}
/>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
</Button>
)}
<div className="flex-1" />
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit} disabled={isSaving}>
{isSaving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{isEdit ? '저장' : '등록'}
</Button>
</div>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
loading={isDeleting}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,134 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import {
type CorporateVehicleApi,
type VehicleFormData,
transformVehicleApi,
transformVehicleDropdown,
} from '../types';
// ===== 차량 목록 (페이지네이션) =====
export async function getCorporateVehicles(params: {
page?: number;
perPage?: number;
search?: string;
ownershipType?: string;
status?: string;
}) {
return executePaginatedAction<CorporateVehicleApi, ReturnType<typeof transformVehicleApi>>({
url: buildApiUrl('/api/v1/corporate-vehicles', {
page: params.page,
per_page: params.perPage,
search: params.search,
ownership_type: params.ownershipType !== 'all' ? params.ownershipType : undefined,
status: params.status !== 'all' ? params.status : undefined,
}),
transform: transformVehicleApi,
errorMessage: '차량 목록 조회에 실패했습니다.',
});
}
// ===== 차량 단건 조회 =====
export async function getCorporateVehicleById(id: number) {
return executeServerAction<CorporateVehicleApi, ReturnType<typeof transformVehicleApi>>({
url: buildApiUrl(`/api/v1/corporate-vehicles/${id}`),
transform: transformVehicleApi,
errorMessage: '차량 정보 조회에 실패했습니다.',
});
}
// ===== 차량 등록 =====
export async function createCorporateVehicle(formData: VehicleFormData) {
const isCorporate = formData.ownershipType === 'corporate';
return executeServerAction({
url: buildApiUrl('/api/v1/corporate-vehicles'),
method: 'POST',
body: {
plate_number: formData.plateNumber,
model: formData.model,
vehicle_type: formData.vehicleType,
ownership_type: formData.ownershipType,
year: formData.year ? Number(formData.year) : null,
driver: formData.driver || null,
status: formData.status || 'active',
memo: formData.memo || null,
// 법인
purchase_date: isCorporate ? formData.purchaseDate || null : null,
purchase_price: isCorporate ? Number(formData.purchasePrice) || 0 : 0,
// 렌트/리스
contract_date: !isCorporate ? formData.contractDate || null : null,
rent_company: formData.rentCompany || null,
rent_company_tel: formData.rentCompanyTel || null,
rent_period: formData.rentPeriod || null,
agreed_mileage: formData.agreedMileage || null,
vehicle_price: Number(formData.vehiclePrice) || 0,
residual_value: Number(formData.residualValue) || 0,
deposit: Number(formData.deposit) || 0,
monthly_rent: !isCorporate ? Number(formData.monthlyRent) || 0 : 0,
monthly_rent_tax: Number(formData.monthlyRentTax) || 0,
mileage: Number(formData.mileage) || 0,
insurance_company: formData.insuranceCompany || null,
insurance_company_tel: formData.insuranceCompanyTel || null,
},
errorMessage: '차량 등록에 실패했습니다.',
});
}
// ===== 차량 수정 =====
export async function updateCorporateVehicle(id: number, formData: VehicleFormData) {
const isCorporate = formData.ownershipType === 'corporate';
return executeServerAction({
url: buildApiUrl(`/api/v1/corporate-vehicles/${id}`),
method: 'PUT',
body: {
plate_number: formData.plateNumber,
model: formData.model,
vehicle_type: formData.vehicleType,
ownership_type: formData.ownershipType,
year: formData.year ? Number(formData.year) : null,
driver: formData.driver || null,
status: formData.status || 'active',
memo: formData.memo || null,
purchase_date: isCorporate ? formData.purchaseDate || null : null,
purchase_price: isCorporate ? Number(formData.purchasePrice) || 0 : 0,
contract_date: !isCorporate ? formData.contractDate || null : null,
rent_company: formData.rentCompany || null,
rent_company_tel: formData.rentCompanyTel || null,
rent_period: formData.rentPeriod || null,
agreed_mileage: formData.agreedMileage || null,
vehicle_price: Number(formData.vehiclePrice) || 0,
residual_value: Number(formData.residualValue) || 0,
deposit: Number(formData.deposit) || 0,
monthly_rent: !isCorporate ? Number(formData.monthlyRent) || 0 : 0,
monthly_rent_tax: Number(formData.monthlyRentTax) || 0,
mileage: Number(formData.mileage) || 0,
insurance_company: formData.insuranceCompany || null,
insurance_company_tel: formData.insuranceCompanyTel || null,
},
errorMessage: '차량 수정에 실패했습니다.',
});
}
// ===== 차량 삭제 =====
export async function deleteCorporateVehicle(id: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/corporate-vehicles/${id}`),
method: 'DELETE',
errorMessage: '차량 삭제에 실패했습니다.',
});
}
// ===== 드롭다운 목록 =====
export async function getVehicleDropdown() {
return executeServerAction<
Array<{ id: number; plate_number: string; model: string }>,
ReturnType<typeof transformVehicleDropdown>[]
>({
url: buildApiUrl('/api/v1/corporate-vehicles/dropdown'),
transform: (data) => data.map(transformVehicleDropdown),
errorMessage: '차량 드롭다운 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,470 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Car, DollarSign, CreditCard, Gauge } from 'lucide-react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { Trash2, Download } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { VehicleFormDialog } from './VehicleFormDialog';
import { getCorporateVehicles, deleteCorporateVehicle } from './actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import {
type CorporateVehicle,
type OwnershipType,
type VehicleStatus,
OWNERSHIP_LABELS,
OWNERSHIP_COLORS,
STATUS_LABELS,
STATUS_COLORS,
formatCurrency,
formatDistance,
} from '../types';
const PAGE_SIZE = 20;
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'vehicle', label: '차량', className: 'min-w-[200px]' },
{ key: 'plateNumber', label: '차량번호', className: 'w-[120px]', copyable: true },
{ key: 'ownershipType', label: '구분', className: 'text-center w-[100px]' },
{ key: 'driver', label: '운전자', className: 'w-[80px]' },
{ key: 'price', label: '취득가/월렌트료', className: 'text-right w-[140px]' },
{ key: 'status', label: '상태', className: 'text-center w-[80px]' },
];
export function CorporateVehicleList() {
const [data, setData] = useState<CorporateVehicle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [search, setSearch] = useState('');
const [filterOwnership, setFilterOwnership] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 모달 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [selectedVehicle, setSelectedVehicle] = useState<CorporateVehicle | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CorporateVehicle | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'corporate-vehicles',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'vehicle', 'plateNumber'],
});
// 데이터 조회
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getCorporateVehicles({
page: currentPage,
perPage: PAGE_SIZE,
search: search || undefined,
ownershipType: filterOwnership,
status: filterStatus,
});
if (result.success) {
setData(result.data);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
} else {
toast.error(result.error || '조회에 실패했습니다.');
setData([]);
}
} catch {
toast.error('조회 중 오류가 발생했습니다.');
setData([]);
} finally {
setIsLoading(false);
}
}, [currentPage, search, filterOwnership, filterStatus]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === data.length
? new Set()
: new Set(data.map((item) => String(item.id)))
);
}, [data]);
// 모달 핸들러
const handleCreate = useCallback(() => {
setSelectedVehicle(null);
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleEdit = useCallback((vehicle: CorporateVehicle) => {
setSelectedVehicle(vehicle);
setDialogMode('edit');
setDialogOpen(true);
}, []);
const handleDeleteClick = useCallback((vehicle: CorporateVehicle) => {
setDeleteTarget(vehicle);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteCorporateVehicle(deleteTarget.id);
if (result.success) {
toast.success('차량이 삭제되었습니다.');
setDeleteOpen(false);
setDeleteTarget(null);
fetchData();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
}, [deleteTarget, fetchData]);
// 엑셀 다운로드
const excelColumns: ExcelColumn<CorporateVehicle>[] = useMemo(() => [
{ header: '차량번호', key: 'plateNumber', width: 15 },
{ header: '모델', key: 'model', width: 20 },
{ header: '종류', key: 'vehicleType', width: 10 },
{ header: '연식', key: 'year', width: 8 },
{ header: '구분', key: 'ownershipType', width: 10, transform: (val) => OWNERSHIP_LABELS[val as OwnershipType] || String(val) },
{ header: '운전자', key: 'driver', width: 10 },
{ header: '취득가', key: 'purchasePrice', width: 15, transform: (val) => Number(val) || 0 },
{ header: '월렌트료', key: 'monthlyRent', width: 15, transform: (val) => Number(val) || 0 },
{ header: '주행거리(km)', key: 'mileage', width: 15, transform: (val) => Number(val) || 0 },
{ header: '상태', key: 'status', width: 10, transform: (val) => STATUS_LABELS[val as VehicleStatus] || String(val) },
], []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: CorporateVehicle[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getCorporateVehicles({
page,
perPage: 100,
search: search || undefined,
ownershipType: filterOwnership,
status: filterStatus,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination.lastPage;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({ data: allData as unknown as Record<string, unknown>[], columns: excelColumns as unknown as ExcelColumn[], filename: '법인차량목록', sheetName: '법인차량' });
toast.success(`${allData.length}건 다운로드 완료`);
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [search, filterOwnership, filterStatus, excelColumns]);
// 통계
const stats = useMemo(() => {
const corporatePrice = data
.filter((v) => v.ownershipType === 'corporate')
.reduce((sum, v) => sum + (v.purchasePrice || 0), 0);
const monthlyRent = data
.filter((v) => v.ownershipType === 'rent' || v.ownershipType === 'lease')
.reduce((sum, v) => sum + (v.monthlyRent || 0), 0);
const monthlyCount = data.filter(
(v) => v.ownershipType === 'rent' || v.ownershipType === 'lease'
).length;
const totalMileage = data.reduce((sum, v) => sum + (v.mileage || 0), 0);
const activeCount = data.filter((v) => v.status === 'active').length;
return [
{
label: '총 차량',
value: `${totalItems}`,
description: `운행중 ${activeCount}`,
icon: Car,
iconColor: 'text-gray-600' as const,
},
{
label: '법인차량 취득가',
value: formatCurrency(corporatePrice),
description: `${data.filter((v) => v.ownershipType === 'corporate').length}`,
icon: DollarSign,
iconColor: 'text-blue-600' as const,
},
{
label: '월 렌트/리스료',
value: formatCurrency(monthlyRent),
description: `${monthlyCount}`,
icon: CreditCard,
iconColor: 'text-blue-600' as const,
},
{
label: '총 주행거리',
value: formatDistance(totalMileage),
icon: Gauge,
iconColor: 'text-gray-600' as const,
},
];
}, [data, totalItems]);
// 통합 필터 (PC: 인라인, 모바일: 바텀시트 자동 분기)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'ownership',
label: '구분',
type: 'single' as const,
options: [
{ value: 'corporate', label: '법인차량' },
{ value: 'rent', label: '렌트차량' },
{ value: 'lease', label: '리스차량' },
],
allOptionLabel: '전체 구분',
},
{
key: 'status',
label: '상태',
type: 'single' as const,
options: [
{ value: 'active', label: '운행중' },
{ value: 'maintenance', label: '정비중' },
{ value: 'disposed', label: '처분' },
],
allOptionLabel: '전체 상태',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
ownership: filterOwnership,
status: filterStatus,
}), [filterOwnership, filterStatus]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'ownership') { setFilterOwnership(value as string); setCurrentPage(1); }
if (key === 'status') { setFilterStatus(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterOwnership('all');
setFilterStatus('all');
setCurrentPage(1);
}, []);
// 테이블 행 렌더
const renderTableRow = useCallback(
(item: CorporateVehicle, _index: number, globalIndex: number) => {
const isCorporate = item.ownershipType === 'corporate';
const priceText = isCorporate
? formatCurrency(item.purchasePrice || 0)
: `${formatCurrency(item.monthlyRent || 0)}/월`;
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(String(item.id))}
onCheckedChange={() => toggleSelection(String(item.id))}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>
<div>
<div className="font-medium truncate max-w-[200px]">{item.model}</div>
<div className="text-xs text-muted-foreground">
{item.vehicleType}{item.year ? ` · ${item.year}` : ''}
</div>
</div>
</TableCell>
<TableCell className="font-mono">{item.plateNumber}</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${OWNERSHIP_COLORS[item.ownershipType]}`}>
{OWNERSHIP_LABELS[item.ownershipType]}
</span>
</TableCell>
<TableCell>{item.driver || '-'}</TableCell>
<TableCell className="text-right">{priceText}</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[item.status]}`}>
{STATUS_LABELS[item.status]}
</span>
</TableCell>
</TableRow>
);
},
[handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더
const renderMobileCard = useCallback(
(
item: CorporateVehicle,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const isCorporate = item.ownershipType === 'corporate';
const priceText = isCorporate
? formatCurrency(item.purchasePrice || 0)
: `${formatCurrency(item.monthlyRent || 0)}/월`;
return (
<MobileCard
key={item.id}
title={item.model}
subtitle={`${item.plateNumber} · ${item.vehicleType}${item.year ? ` · ${item.year}` : ''}`}
headerBadges={
<div className="flex gap-1">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${OWNERSHIP_COLORS[item.ownershipType]}`}>
{OWNERSHIP_LABELS[item.ownershipType]}
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[item.status]}`}>
{STATUS_LABELS[item.status]}
</span>
</div>
}
infoGrid={[
<InfoField key="driver" label="운전자" value={item.driver || '-'} />,
<InfoField key="price" label={isCorporate ? '취득가' : '월렌트료'} value={priceText} />,
<InfoField key="mileage" label="주행거리" value={formatDistance(item.mileage)} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleEdit(item)}
/>
);
},
[handleEdit]
);
return (
<>
<IntegratedListTemplateV2<CorporateVehicle>
title="법인차량관리"
description="Corporate Vehicles"
icon={Car}
// 검색
searchValue={search}
onSearchChange={(v) => { setSearch(v); setCurrentPage(1); }}
searchPlaceholder="차량번호, 모델, 운전자 검색..."
// 헤더 액션 (엑셀 다운로드)
headerActions={(
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
// 등록 버튼
createButton={{ label: '차량 등록', onClick: handleCreate }}
// 통계
stats={stats}
// 통합 필터 (PC: 인라인, 모바일: 바텀시트)
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="차량 필터"
// 컬럼
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage: PAGE_SIZE,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
<VehicleFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
vehicle={selectedVehicle}
onSuccess={fetchData}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description={`${deleteTarget?.model || ''} (${deleteTarget?.plateNumber || ''})을(를) 삭제하시겠습니까?`}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,408 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { ArrowUpDown, Loader2 } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
type VehicleLog,
type VehicleLogFormData,
type VehicleDropdownItem,
type TripType,
type LocationType,
EMPTY_LOG_FORM,
TRIP_TYPE_LABELS,
LOCATION_TYPE_LABELS,
NOTE_PRESETS,
} from '../types';
import {
createVehicleLog,
updateVehicleLog,
deleteVehicleLog,
} from './actions';
interface VehicleLogFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
log?: VehicleLog | null;
vehicles: VehicleDropdownItem[];
onSuccess: () => void;
}
export function VehicleLogFormDialog({
open,
onOpenChange,
mode,
log,
vehicles,
onSuccess,
}: VehicleLogFormDialogProps) {
const [form, setForm] = useState<VehicleLogFormData>(EMPTY_LOG_FORM);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
useEffect(() => {
if (!open) return;
if (isEdit && log) {
setForm({
vehicleId: String(log.vehicleId),
logDate: log.logDate,
department: log.department || '',
driverName: log.driverName,
tripType: log.tripType,
departureType: log.departureType,
departureName: log.departureName || '',
departureAddress: log.departureAddress || '',
arrivalType: log.arrivalType,
arrivalName: log.arrivalName || '',
arrivalAddress: log.arrivalAddress || '',
distanceKm: log.distanceKm ? String(log.distanceKm) : '',
note: log.note || '',
});
} else {
setForm({
...EMPTY_LOG_FORM,
logDate: new Date().toISOString().slice(0, 10),
});
}
}, [open, isEdit, log]);
const updateField = useCallback(
<K extends keyof VehicleLogFormData>(key: K, value: VehicleLogFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
// 출발↔도착 교환
const handleSwapLocations = useCallback(() => {
setForm((prev) => {
// trip_type 자동 전환
let newTripType = prev.tripType;
if (prev.tripType === 'commute_to') newTripType = 'commute_from';
else if (prev.tripType === 'commute_from') newTripType = 'commute_to';
return {
...prev,
tripType: newTripType,
departureType: prev.arrivalType,
departureName: prev.arrivalName,
departureAddress: prev.arrivalAddress,
arrivalType: prev.departureType,
arrivalName: prev.departureName,
arrivalAddress: prev.departureAddress,
};
});
}, []);
// 비고 프리셋 삽입
const handleNotePreset = useCallback((preset: string) => {
setForm((prev) => ({
...prev,
note: prev.note ? `${prev.note} ${preset}` : preset,
}));
}, []);
const handleSubmit = async () => {
if (!form.vehicleId || !form.tripType || !form.driverName) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = isEdit && log
? await updateVehicleLog(log.id, form)
: await createVehicleLog(form);
if (result.success) {
toast.success(isEdit ? '운행기록이 수정되었습니다.' : '운행기록이 등록되었습니다.');
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!log) return;
setIsDeleting(true);
try {
const result = await deleteVehicleLog(log.id);
if (result.success) {
toast.success('운행기록이 삭제되었습니다.');
setDeleteOpen(false);
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle>{isEdit ? '운행기록 수정' : '운행기록 등록'}</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={handleSwapLocations}
className="text-xs"
>
<ArrowUpDown className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Row 1: 날짜, 차량, 구분 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={form.logDate}
onChange={(v) => updateField('logDate', v)}
placeholder="날짜 선택"
/>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`v-${form.vehicleId}`}
value={form.vehicleId}
onValueChange={(v) => updateField('vehicleId', v)}
>
<SelectTrigger><SelectValue placeholder="차량 선택" /></SelectTrigger>
<SelectContent>
{vehicles.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
{v.plateNumber} ({v.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Select
key={`tt-${form.tripType}`}
value={form.tripType}
onValueChange={(v) => updateField('tripType', v as TripType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(Object.entries(TRIP_TYPE_LABELS) as [TripType, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 부서, 운전자, 주행거리 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.department}
onChange={(e) => updateField('department', e.target.value)}
placeholder="부서"
/>
</div>
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.driverName}
onChange={(e) => updateField('driverName', e.target.value)}
placeholder="운전자명"
/>
</div>
<div className="space-y-1.5">
<Label> (km) <span className="text-red-500">*</span></Label>
<Input
type="number"
value={form.distanceKm}
onChange={(e) => updateField('distanceKm', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* 출발지 */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-green-500" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select
key={`dt-${form.departureType}`}
value={form.departureType as string}
onValueChange={(v) => updateField('departureType', v as LocationType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(Object.entries(LOCATION_TYPE_LABELS) as [LocationType, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.departureName}
onChange={(e) => updateField('departureName', e.target.value)}
placeholder="출발지명"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.departureAddress}
onChange={(e) => updateField('departureAddress', e.target.value)}
placeholder="주소"
/>
</div>
</div>
</div>
{/* 도착지 */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-red-500" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select
key={`at-${form.arrivalType}`}
value={form.arrivalType as string}
onValueChange={(v) => updateField('arrivalType', v as LocationType)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(Object.entries(LOCATION_TYPE_LABELS) as [LocationType, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.arrivalName}
onChange={(e) => updateField('arrivalName', e.target.value)}
placeholder="도착지명"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={form.arrivalAddress}
onChange={(e) => updateField('arrivalAddress', e.target.value)}
placeholder="주소"
/>
</div>
</div>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-1.5 mb-2">
{NOTE_PRESETS.map((preset) => (
<Button
key={preset}
variant="outline"
size="sm"
className="text-xs h-7"
onClick={() => handleNotePreset(preset)}
>
{preset}
</Button>
))}
</div>
<Textarea
value={form.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="직접 입력"
rows={2}
/>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
</Button>
)}
<div className="flex-1" />
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSaving}
className={isEdit ? '' : 'bg-green-600 hover:bg-green-700'}
>
{isSaving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{isEdit ? '저장' : '등록'}
</Button>
</div>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 운행기록을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,143 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import {
type VehicleLogApi,
type VehicleLogFormData,
type VehicleLogSummary,
transformVehicleLogApi,
} from '../types';
// ===== 운행기록 목록 (페이지네이션) =====
export async function getVehicleLogs(params: {
page?: number;
perPage?: number;
search?: string;
vehicleId?: string;
year?: number;
month?: number;
tripType?: string;
}) {
return executePaginatedAction<VehicleLogApi, ReturnType<typeof transformVehicleLogApi>>({
url: buildApiUrl('/api/v1/vehicle-logs', {
page: params.page,
per_page: params.perPage,
search: params.search,
vehicle_id: params.vehicleId !== 'all' ? params.vehicleId : undefined,
year: params.year,
month: params.month,
trip_type: params.tripType !== 'all' ? params.tripType : undefined,
}),
transform: transformVehicleLogApi,
errorMessage: '운행기록 목록 조회에 실패했습니다.',
});
}
// ===== 운행기록 단건 조회 =====
export async function getVehicleLogById(id: number) {
return executeServerAction<VehicleLogApi, ReturnType<typeof transformVehicleLogApi>>({
url: buildApiUrl(`/api/v1/vehicle-logs/${id}`),
transform: transformVehicleLogApi,
errorMessage: '운행기록 조회에 실패했습니다.',
});
}
// ===== 운행기록 등록 =====
export async function createVehicleLog(formData: VehicleLogFormData) {
return executeServerAction({
url: buildApiUrl('/api/v1/vehicle-logs'),
method: 'POST',
body: {
vehicle_id: Number(formData.vehicleId),
log_date: formData.logDate,
department: formData.department || null,
driver_name: formData.driverName,
trip_type: formData.tripType,
departure_type: formData.departureType || null,
departure_name: formData.departureName || null,
departure_address: formData.departureAddress || null,
arrival_type: formData.arrivalType || null,
arrival_name: formData.arrivalName || null,
arrival_address: formData.arrivalAddress || null,
distance_km: Number(formData.distanceKm) || 0,
note: formData.note || null,
},
errorMessage: '운행기록 등록에 실패했습니다.',
});
}
// ===== 운행기록 수정 =====
export async function updateVehicleLog(id: number, formData: VehicleLogFormData) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-logs/${id}`),
method: 'PUT',
body: {
vehicle_id: Number(formData.vehicleId),
log_date: formData.logDate,
department: formData.department || null,
driver_name: formData.driverName,
trip_type: formData.tripType,
departure_type: formData.departureType || null,
departure_name: formData.departureName || null,
departure_address: formData.departureAddress || null,
arrival_type: formData.arrivalType || null,
arrival_name: formData.arrivalName || null,
arrival_address: formData.arrivalAddress || null,
distance_km: Number(formData.distanceKm) || 0,
note: formData.note || null,
},
errorMessage: '운행기록 수정에 실패했습니다.',
});
}
// ===== 운행기록 삭제 =====
export async function deleteVehicleLog(id: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-logs/${id}`),
method: 'DELETE',
errorMessage: '운행기록 삭제에 실패했습니다.',
});
}
// ===== 월별 통계 =====
interface SummaryApi {
total_distance: number;
total_count: number;
commute_to_distance: number;
commute_to_count: number;
commute_from_distance: number;
commute_from_count: number;
business_distance: number;
business_count: number;
personal_distance: number;
personal_count: number;
}
export async function getVehicleLogSummary(params: {
vehicleId?: string;
year: number;
month: number;
}) {
return executeServerAction<SummaryApi, VehicleLogSummary>({
url: buildApiUrl('/api/v1/vehicle-logs/summary', {
vehicle_id: params.vehicleId !== 'all' ? params.vehicleId : undefined,
year: params.year,
month: params.month,
}),
transform: (api) => ({
totalDistance: api.total_distance,
totalCount: api.total_count,
commuteToDistance: api.commute_to_distance,
commuteToCount: api.commute_to_count,
commuteFromDistance: api.commute_from_distance,
commuteFromCount: api.commute_from_count,
businessDistance: api.business_distance,
businessCount: api.business_count,
personalDistance: api.personal_distance,
personalCount: api.personal_count,
}),
errorMessage: '운행 통계 조회에 실패했습니다.',
});
}

View File

@@ -0,0 +1,582 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { BookOpen, Route, Briefcase, User, MapPin, Copy, Edit, Trash2, Download } from 'lucide-react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { VehicleLogFormDialog } from './VehicleLogFormDialog';
import { getVehicleLogs, getVehicleLogSummary, deleteVehicleLog } from './actions';
import { getVehicleDropdown } from '../CorporateVehicles/actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import {
type VehicleLog,
type VehicleDropdownItem,
type VehicleLogSummary,
type TripType,
TRIP_TYPE_LABELS,
TRIP_TYPE_COLORS,
LOCATION_TYPE_LABELS,
type LocationType,
formatDistance,
} from '../types';
const PAGE_SIZE = 20;
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'logDate', label: '날짜', className: 'w-[100px]' },
{ key: 'vehicle', label: '차량', className: 'w-[140px]' },
{ key: 'driver', label: '부서/성명', className: 'w-[120px]' },
{ key: 'tripType', label: '구분', className: 'text-center w-[90px]' },
{ key: 'departure', label: '출발지', className: 'min-w-[140px]' },
{ key: 'arrival', label: '도착지', className: 'min-w-[140px]' },
{ key: 'distanceKm', label: '주행(km)', className: 'text-right w-[90px]' },
{ key: 'note', label: '비고', className: 'min-w-[120px]' },
{ key: 'actions', label: '관리', className: 'text-center w-[100px]' },
];
const now = new Date();
const CURRENT_YEAR = now.getFullYear();
const CURRENT_MONTH = now.getMonth() + 1;
const YEAR_OPTIONS = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR - 2 + i);
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => i + 1);
export function VehicleLogList() {
const [data, setData] = useState<VehicleLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [search, setSearch] = useState('');
const [year, setYear] = useState(CURRENT_YEAR);
const [month, setMonth] = useState(CURRENT_MONTH);
const [filterVehicle, setFilterVehicle] = useState('all');
const [filterTripType, setFilterTripType] = useState('all');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 드롭다운 차량 목록
const [vehicles, setVehicles] = useState<VehicleDropdownItem[]>([]);
// 월별 통계
const [summary, setSummary] = useState<VehicleLogSummary | null>(null);
// 모달 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [selectedLog, setSelectedLog] = useState<VehicleLog | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<VehicleLog | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'vehicle-logs',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'logDate', 'vehicle', 'tripType', 'actions'],
});
// 차량 드롭다운 로드
useEffect(() => {
getVehicleDropdown().then((result) => {
if (result.success && result.data) {
setVehicles(Array.isArray(result.data) ? result.data : []);
}
});
}, []);
// 데이터 조회
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getVehicleLogs({
page: currentPage,
perPage: PAGE_SIZE,
search: search || undefined,
vehicleId: filterVehicle,
year,
month,
tripType: filterTripType,
});
if (result.success) {
setData(result.data);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
} else {
toast.error(result.error || '조회에 실패했습니다.');
setData([]);
}
} catch {
toast.error('조회 중 오류가 발생했습니다.');
setData([]);
} finally {
setIsLoading(false);
}
}, [currentPage, search, filterVehicle, year, month, filterTripType]);
// 통계 조회
const fetchSummary = useCallback(async () => {
try {
const result = await getVehicleLogSummary({
vehicleId: filterVehicle,
year,
month,
});
if (result.success && result.data) {
setSummary(result.data);
}
} catch {
// 통계 실패 시 무시
}
}, [filterVehicle, year, month]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
fetchSummary();
}, [fetchSummary]);
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === data.length
? new Set()
: new Set(data.map((item) => String(item.id)))
);
}, [data]);
// 모달 핸들러
const handleCreate = useCallback(() => {
setSelectedLog(null);
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleEdit = useCallback((log: VehicleLog) => {
setSelectedLog(log);
setDialogMode('edit');
setDialogOpen(true);
}, []);
// 복사 (기존 기록 기반, 날짜만 오늘)
const handleCopy = useCallback((log: VehicleLog) => {
setSelectedLog({ ...log, id: 0, logDate: new Date().toISOString().slice(0, 10) });
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleDeleteClick = useCallback((log: VehicleLog) => {
setDeleteTarget(log);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteVehicleLog(deleteTarget.id);
if (result.success) {
toast.success('운행기록이 삭제되었습니다.');
setDeleteOpen(false);
setDeleteTarget(null);
fetchData();
fetchSummary();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
}, [deleteTarget, fetchData, fetchSummary]);
const handleSuccess = useCallback(() => {
fetchData();
fetchSummary();
}, [fetchData, fetchSummary]);
// 엑셀 다운로드
const excelColumns: ExcelColumn<VehicleLog>[] = useMemo(() => [
{ header: '날짜', key: 'logDate', width: 12 },
{ header: '차량번호', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.plateNumber || '-' },
{ header: '차량모델', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.model || '-' },
{ header: '운전자', key: 'driverName', width: 10 },
{ header: '부서', key: 'department', width: 10 },
{ header: '구분', key: 'tripType', width: 12, transform: (val) => TRIP_TYPE_LABELS[val as TripType] || String(val) },
{ header: '출발지', key: 'departureName', width: 15 },
{ header: '도착지', key: 'arrivalName', width: 15 },
{ header: '주행거리(km)', key: 'distanceKm', width: 15, transform: (val) => Number(val) || 0 },
{ header: '비고', key: 'note', width: 20 },
], []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: VehicleLog[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getVehicleLogs({
page,
perPage: 100,
search: search || undefined,
vehicleId: filterVehicle,
year,
month,
tripType: filterTripType,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination.lastPage;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({ data: allData as unknown as Record<string, unknown>[], columns: excelColumns as unknown as ExcelColumn[], filename: '차량일지', sheetName: '차량일지' });
toast.success(`${allData.length}건 다운로드 완료`);
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [search, filterVehicle, year, month, filterTripType, excelColumns]);
// 통계
const stats = useMemo(() => {
const s = summary;
return [
{
label: '전체',
value: formatDistance(s?.totalDistance ?? 0),
description: `${s?.totalCount ?? 0}`,
icon: Route,
iconColor: 'text-gray-600' as const,
},
{
label: '출근용',
value: formatDistance(s?.commuteToDistance ?? 0),
description: `${s?.commuteToCount ?? 0}`,
icon: MapPin,
iconColor: 'text-green-600' as const,
},
{
label: '퇴근용',
value: formatDistance(s?.commuteFromDistance ?? 0),
description: `${s?.commuteFromCount ?? 0}`,
icon: MapPin,
iconColor: 'text-green-600' as const,
},
{
label: '업무용',
value: formatDistance(s?.businessDistance ?? 0),
description: `${s?.businessCount ?? 0}`,
icon: Briefcase,
iconColor: 'text-blue-600' as const,
},
{
label: '비업무',
value: formatDistance(s?.personalDistance ?? 0),
description: `${s?.personalCount ?? 0}`,
icon: User,
iconColor: 'text-gray-600' as const,
},
];
}, [summary]);
// 통합 필터 (PC: 인라인, 모바일: 바텀시트 자동 분기)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'vehicle',
label: '차량',
type: 'single' as const,
options: vehicles.map((v) => ({ value: String(v.id), label: `${v.plateNumber} (${v.model})` })),
allOptionLabel: '전체 차량',
},
{
key: 'tripType',
label: '구분',
type: 'single' as const,
options: (Object.entries(TRIP_TYPE_LABELS) as [TripType, string][]).map(([key, label]) => ({
value: key,
label,
})),
allOptionLabel: '전체 구분',
},
], [vehicles]);
const filterValues: FilterValues = useMemo(() => ({
vehicle: filterVehicle,
tripType: filterTripType,
}), [filterVehicle, filterTripType]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'vehicle') { setFilterVehicle(value as string); setCurrentPage(1); }
if (key === 'tripType') { setFilterTripType(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterVehicle('all');
setFilterTripType('all');
setCurrentPage(1);
}, []);
// 위치 정보 표시 함수
const formatLocation = (type: LocationType | string, name: string | null) => {
const typeLabel = LOCATION_TYPE_LABELS[type as LocationType] || '';
return name ? `${typeLabel ? `[${typeLabel}] ` : ''}${name}` : typeLabel || '-';
};
// 테이블 행 렌더
const renderTableRow = useCallback(
(item: VehicleLog, _index: number, globalIndex: number) => {
const vehicleText = item.vehicle
? `${item.vehicle.plateNumber}`
: '-';
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(String(item.id))}
onCheckedChange={() => toggleSelection(String(item.id))}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-mono text-sm">{item.logDate}</TableCell>
<TableCell>
<div className="truncate max-w-[130px]">{vehicleText}</div>
{item.vehicle && (
<div className="text-xs text-muted-foreground truncate">{item.vehicle.model}</div>
)}
</TableCell>
<TableCell>
<div>{item.driverName}</div>
{item.department && (
<div className="text-xs text-muted-foreground">{item.department}</div>
)}
</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${TRIP_TYPE_COLORS[item.tripType]}`}>
{TRIP_TYPE_LABELS[item.tripType]}
</span>
</TableCell>
<TableCell className="text-sm">
{formatLocation(item.departureType, item.departureName)}
</TableCell>
<TableCell className="text-sm">
{formatLocation(item.arrivalType, item.arrivalName)}
</TableCell>
<TableCell className="text-right font-mono">
{item.distanceKm ? item.distanceKm.toLocaleString() : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{item.note || '-'}
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(item)}>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCopy(item)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteClick(item)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
},
[handleEdit, handleCopy, handleDeleteClick]
);
// 모바일 카드 렌더
const renderMobileCard = useCallback(
(
item: VehicleLog,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const vehicleText = item.vehicle
? `${item.vehicle.plateNumber} (${item.vehicle.model})`
: '-';
return (
<MobileCard
key={item.id}
title={`${item.logDate} · ${item.driverName}`}
subtitle={vehicleText}
headerBadges={
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${TRIP_TYPE_COLORS[item.tripType]}`}>
{TRIP_TYPE_LABELS[item.tripType]}
</span>
}
infoGrid={[
<InfoField key="departure" label="출발지" value={formatLocation(item.departureType, item.departureName)} />,
<InfoField key="arrival" label="도착지" value={formatLocation(item.arrivalType, item.arrivalName)} />,
<InfoField key="distance" label="주행거리" value={item.distanceKm ? formatDistance(item.distanceKm) : '-'} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleEdit(item)}
/>
);
},
[handleEdit]
);
return (
<>
<IntegratedListTemplateV2<VehicleLog>
title="차량일지"
description="Vehicle Driving Logs"
icon={BookOpen}
// 검색
searchValue={search}
onSearchChange={(v) => { setSearch(v); setCurrentPage(1); }}
searchPlaceholder="운전자, 출발지, 도착지 검색..."
// 날짜 선택 (year/month)
dateRangeSelector={{
enabled: true,
hideDateInputs: true,
showPresets: false,
extraActions: (
<div className="flex items-center gap-2 flex-wrap">
<Select value={String(year)} onValueChange={(v) => { setYear(Number(v)); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{YEAR_OPTIONS.map((y) => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(month)} onValueChange={(v) => { setMonth(Number(v)); setCurrentPage(1); }}>
<SelectTrigger className="w-[80px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTH_OPTIONS.map((m) => (
<SelectItem key={m} value={String(m)}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
}}
// 헤더 액션 (엑셀 다운로드)
headerActions={(
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
// 등록 버튼
createButton={{ label: '운행기록 등록', onClick: handleCreate }}
// 통계
stats={stats}
// 통합 필터 (PC: 인라인, 모바일: 바텀시트)
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="운행 필터"
// 컬럼
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage: PAGE_SIZE,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
<VehicleLogFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
log={selectedLog}
vehicles={vehicles}
onSuccess={handleSuccess}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 운행기록을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,278 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { Loader2 } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
type VehicleMaintenance,
type MaintenanceFormData,
type MaintenanceCategory,
type VehicleDropdownItem,
EMPTY_MAINTENANCE_FORM,
MAINTENANCE_CATEGORIES,
} from '../types';
import {
createVehicleMaintenance,
updateVehicleMaintenance,
deleteVehicleMaintenance,
} from './actions';
interface MaintenanceFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'create' | 'edit';
maintenance?: VehicleMaintenance | null;
vehicles: VehicleDropdownItem[];
onSuccess: () => void;
}
export function MaintenanceFormDialog({
open,
onOpenChange,
mode,
maintenance,
vehicles,
onSuccess,
}: MaintenanceFormDialogProps) {
const [form, setForm] = useState<MaintenanceFormData>(EMPTY_MAINTENANCE_FORM);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
useEffect(() => {
if (!open) return;
if (isEdit && maintenance) {
setForm({
vehicleId: String(maintenance.vehicleId),
date: maintenance.date,
category: maintenance.category,
description: maintenance.description || '',
amount: maintenance.amount ? String(maintenance.amount) : '',
mileage: maintenance.mileage ? String(maintenance.mileage) : '',
vendor: maintenance.vendor || '',
memo: maintenance.memo || '',
});
} else {
setForm({
...EMPTY_MAINTENANCE_FORM,
date: new Date().toISOString().slice(0, 10),
});
}
}, [open, isEdit, maintenance]);
const updateField = useCallback(
<K extends keyof MaintenanceFormData>(key: K, value: MaintenanceFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleSubmit = async () => {
if (!form.vehicleId || !form.category || !form.description) {
toast.error('필수 항목을 입력해주세요.');
return;
}
setIsSaving(true);
try {
const result = isEdit && maintenance
? await updateVehicleMaintenance(maintenance.id, form)
: await createVehicleMaintenance(form);
if (result.success) {
toast.success(isEdit ? '비용이 수정되었습니다.' : '비용이 등록되었습니다.');
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!maintenance) return;
setIsDeleting(true);
try {
const result = await deleteVehicleMaintenance(maintenance.id);
if (result.success) {
toast.success('비용이 삭제되었습니다.');
setDeleteOpen(false);
onOpenChange(false);
onSuccess();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? '비용 수정' : '비용 등록'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Row 1: 날짜, 구분 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker
value={form.date}
onChange={(v) => updateField('date', v)}
placeholder="날짜 선택"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
key={`cat-${form.category}`}
value={form.category as string}
onValueChange={(v) => updateField('category', v as MaintenanceCategory)}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{MAINTENANCE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 차량 */}
<div className="space-y-1.5">
<Label></Label>
<Select
key={`v-${form.vehicleId}`}
value={form.vehicleId}
onValueChange={(v) => updateField('vehicleId', v)}
>
<SelectTrigger><SelectValue placeholder="차량 선택" /></SelectTrigger>
<SelectContent>
{vehicles.map((v) => (
<SelectItem key={v.id} value={String(v.id)}>
{v.plateNumber} ({v.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Row 3: 내용 */}
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="내용"
/>
</div>
{/* Row 4: 금액, 주행거리 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> <span className="text-red-500">*</span></Label>
<Input
type="number"
value={form.amount}
onChange={(e) => updateField('amount', e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label>(km)</Label>
<Input
type="number"
value={form.mileage}
onChange={(e) => updateField('mileage', e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 5: 업체, 메모 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.vendor}
onChange={(e) => updateField('vendor', e.target.value)}
placeholder="업체명"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.memo}
onChange={(e) => updateField('memo', e.target.value)}
placeholder="메모"
/>
</div>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center gap-2 pt-2">
{isEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
</Button>
)}
<div className="flex-1" />
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSaving}
className={isEdit ? '' : 'bg-green-600 hover:bg-green-700'}
>
{isSaving && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{isEdit ? '저장' : '등록'}
</Button>
</div>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 비용을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,91 @@
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import {
type VehicleMaintenanceApi,
type MaintenanceFormData,
transformMaintenanceApi,
} from '../types';
// ===== 정비이력 목록 (페이지네이션) =====
export async function getVehicleMaintenances(params: {
page?: number;
perPage?: number;
search?: string;
vehicleId?: string;
category?: string;
startDate?: string;
endDate?: string;
}) {
return executePaginatedAction<VehicleMaintenanceApi, ReturnType<typeof transformMaintenanceApi>>({
url: buildApiUrl('/api/v1/vehicle-maintenances', {
page: params.page,
per_page: params.perPage,
search: params.search,
vehicle_id: params.vehicleId !== 'all' ? params.vehicleId : undefined,
category: params.category !== 'all' ? params.category : undefined,
start_date: params.startDate,
end_date: params.endDate,
}),
transform: transformMaintenanceApi,
errorMessage: '정비이력 목록 조회에 실패했습니다.',
});
}
// ===== 정비이력 단건 조회 =====
export async function getVehicleMaintenanceById(id: number) {
return executeServerAction<VehicleMaintenanceApi, ReturnType<typeof transformMaintenanceApi>>({
url: buildApiUrl(`/api/v1/vehicle-maintenances/${id}`),
transform: transformMaintenanceApi,
errorMessage: '정비이력 조회에 실패했습니다.',
});
}
// ===== 정비이력 등록 =====
export async function createVehicleMaintenance(formData: MaintenanceFormData) {
return executeServerAction({
url: buildApiUrl('/api/v1/vehicle-maintenances'),
method: 'POST',
body: {
vehicle_id: Number(formData.vehicleId),
date: formData.date,
category: formData.category,
description: formData.description,
amount: Number(formData.amount) || 0,
mileage: Number(formData.mileage) || 0,
vendor: formData.vendor || null,
memo: formData.memo || null,
},
errorMessage: '정비이력 등록에 실패했습니다.',
});
}
// ===== 정비이력 수정 =====
export async function updateVehicleMaintenance(id: number, formData: MaintenanceFormData) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-maintenances/${id}`),
method: 'PUT',
body: {
vehicle_id: Number(formData.vehicleId),
date: formData.date,
category: formData.category,
description: formData.description,
amount: Number(formData.amount) || 0,
mileage: Number(formData.mileage) || 0,
vendor: formData.vendor || null,
memo: formData.memo || null,
},
errorMessage: '정비이력 수정에 실패했습니다.',
});
}
// ===== 정비이력 삭제 =====
export async function deleteVehicleMaintenance(id: number) {
return executeServerAction({
url: buildApiUrl(`/api/v1/vehicle-maintenances/${id}`),
method: 'DELETE',
errorMessage: '정비이력 삭제에 실패했습니다.',
});
}

View File

@@ -0,0 +1,489 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Wrench, Fuel, DollarSign, Gauge, Edit, Trash2, Download } from 'lucide-react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { MaintenanceFormDialog } from './MaintenanceFormDialog';
import { getVehicleMaintenances, deleteVehicleMaintenance } from './actions';
import { getVehicleDropdown } from '../CorporateVehicles/actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import {
type VehicleMaintenance,
type VehicleDropdownItem,
type MaintenanceCategory,
MAINTENANCE_CATEGORIES,
CATEGORY_COLORS,
formatCurrency,
formatDistance,
} from '../types';
const PAGE_SIZE = 20;
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'date', label: '날짜', className: 'w-[100px]' },
{ key: 'vehicle', label: '차량', className: 'w-[140px]' },
{ key: 'category', label: '분류', className: 'text-center w-[80px]' },
{ key: 'description', label: '내용', className: 'min-w-[180px]' },
{ key: 'amount', label: '금액', className: 'text-right w-[120px]' },
{ key: 'mileage', label: '주행(km)', className: 'text-right w-[90px]' },
{ key: 'vendor', label: '업체', className: 'w-[120px]' },
{ key: 'actions', label: '관리', className: 'text-center w-[80px]' },
];
export function VehicleMaintenanceList() {
const [data, setData] = useState<VehicleMaintenance[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [search, setSearch] = useState('');
const [filterVehicle, setFilterVehicle] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 드롭다운 차량 목록
const [vehicles, setVehicles] = useState<VehicleDropdownItem[]>([]);
// 모달 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [selectedItem, setSelectedItem] = useState<VehicleMaintenance | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<VehicleMaintenance | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'vehicle-maintenance',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'date', 'vehicle', 'category', 'actions'],
});
// 차량 드롭다운 로드
useEffect(() => {
getVehicleDropdown().then((result) => {
if (result.success && result.data) {
setVehicles(Array.isArray(result.data) ? result.data : []);
}
});
}, []);
// 데이터 조회
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getVehicleMaintenances({
page: currentPage,
perPage: PAGE_SIZE,
search: search || undefined,
vehicleId: filterVehicle,
category: filterCategory,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
if (result.success) {
setData(result.data);
setTotalPages(result.pagination.lastPage);
setTotalItems(result.pagination.total);
} else {
toast.error(result.error || '조회에 실패했습니다.');
setData([]);
}
} catch {
toast.error('조회 중 오류가 발생했습니다.');
setData([]);
} finally {
setIsLoading(false);
}
}, [currentPage, search, filterVehicle, filterCategory, startDate, endDate]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds((prev) =>
prev.size === data.length
? new Set()
: new Set(data.map((item) => String(item.id)))
);
}, [data]);
// 모달 핸들러
const handleCreate = useCallback(() => {
setSelectedItem(null);
setDialogMode('create');
setDialogOpen(true);
}, []);
const handleEdit = useCallback((item: VehicleMaintenance) => {
setSelectedItem(item);
setDialogMode('edit');
setDialogOpen(true);
}, []);
const handleDeleteClick = useCallback((item: VehicleMaintenance) => {
setDeleteTarget(item);
setDeleteOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteVehicleMaintenance(deleteTarget.id);
if (result.success) {
toast.success('비용이 삭제되었습니다.');
setDeleteOpen(false);
setDeleteTarget(null);
fetchData();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
}
}, [deleteTarget, fetchData]);
// 엑셀 다운로드
const excelColumns: ExcelColumn<VehicleMaintenance>[] = useMemo(() => [
{ header: '날짜', key: 'date', width: 12 },
{ header: '차량번호', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.plateNumber || '-' },
{ header: '차량모델', key: 'vehicle', width: 15, transform: (_, row) => row.vehicle?.model || '-' },
{ header: '분류', key: 'category', width: 10 },
{ header: '내용', key: 'description', width: 30 },
{ header: '금액', key: 'amount', width: 15, transform: (val) => Number(val) || 0 },
{ header: '주행거리(km)', key: 'mileage', width: 15, transform: (val) => Number(val) || 0 },
{ header: '업체', key: 'vendor', width: 15 },
{ header: '메모', key: 'memo', width: 20 },
], []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: VehicleMaintenance[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getVehicleMaintenances({
page,
perPage: 100,
search: search || undefined,
vehicleId: filterVehicle,
category: filterCategory,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination.lastPage;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({ data: allData as unknown as Record<string, unknown>[], columns: excelColumns as unknown as ExcelColumn[], filename: '정비이력', sheetName: '정비이력' });
toast.success(`${allData.length}건 다운로드 완료`);
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [search, filterVehicle, filterCategory, startDate, endDate, excelColumns]);
// 프론트엔드 통계 (현재 페이지 데이터 기반)
const stats = useMemo(() => {
const totalAmount = data.reduce((sum, v) => sum + (v.amount || 0), 0);
const fuelAmount = data
.filter((v) => v.category === '주유')
.reduce((sum, v) => sum + (v.amount || 0), 0);
const repairAmount = data
.filter((v) => v.category === '정비')
.reduce((sum, v) => sum + (v.amount || 0), 0);
const otherAmount = totalAmount - fuelAmount - repairAmount;
return [
{
label: '총 비용',
value: formatCurrency(totalAmount),
description: `${totalItems}`,
icon: DollarSign,
iconColor: 'text-gray-600' as const,
},
{
label: '주유비',
value: formatCurrency(fuelAmount),
description: `${data.filter((v) => v.category === '주유').length}`,
icon: Fuel,
iconColor: 'text-amber-600' as const,
},
{
label: '정비비',
value: formatCurrency(repairAmount),
description: `${data.filter((v) => v.category === '정비').length}`,
icon: Wrench,
iconColor: 'text-blue-600' as const,
},
{
label: '기타 비용',
value: formatCurrency(otherAmount),
description: `${data.filter((v) => v.category !== '주유' && v.category !== '정비').length}`,
icon: Gauge,
iconColor: 'text-gray-600' as const,
},
];
}, [data, totalItems]);
// 통합 필터 (PC: 인라인, 모바일: 바텀시트 자동 분기)
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'vehicle',
label: '차량',
type: 'single' as const,
options: vehicles.map((v) => ({ value: String(v.id), label: `${v.plateNumber} (${v.model})` })),
allOptionLabel: '전체 차량',
},
{
key: 'category',
label: '분류',
type: 'single' as const,
options: MAINTENANCE_CATEGORIES.map((cat) => ({ value: cat, label: cat })),
allOptionLabel: '전체 분류',
},
], [vehicles]);
const filterValues: FilterValues = useMemo(() => ({
vehicle: filterVehicle,
category: filterCategory,
}), [filterVehicle, filterCategory]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'vehicle') { setFilterVehicle(value as string); setCurrentPage(1); }
if (key === 'category') { setFilterCategory(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterVehicle('all');
setFilterCategory('all');
setCurrentPage(1);
}, []);
// 테이블 행 렌더
const renderTableRow = useCallback(
(item: VehicleMaintenance, _index: number, globalIndex: number) => {
const vehicleText = item.vehicle
? item.vehicle.plateNumber
: '-';
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(String(item.id))}
onCheckedChange={() => toggleSelection(String(item.id))}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-mono text-sm">{item.date}</TableCell>
<TableCell>
<div className="truncate max-w-[130px]">{vehicleText}</div>
{item.vehicle && (
<div className="text-xs text-muted-foreground truncate">{item.vehicle.model}</div>
)}
</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-gray-100 text-gray-700'}`}>
{item.category}
</span>
</TableCell>
<TableCell className="truncate max-w-[180px]">{item.description}</TableCell>
<TableCell className="text-right font-mono">{formatCurrency(item.amount)}</TableCell>
<TableCell className="text-right font-mono">
{item.mileage ? item.mileage.toLocaleString() : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{item.vendor || '-'}
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(item)}>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteClick(item)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
},
[handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더
const renderMobileCard = useCallback(
(
item: VehicleMaintenance,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
const vehicleText = item.vehicle
? `${item.vehicle.plateNumber} (${item.vehicle.model})`
: '-';
return (
<MobileCard
key={item.id}
title={item.description}
subtitle={`${item.date} · ${vehicleText}`}
headerBadges={
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-gray-100 text-gray-700'}`}>
{item.category}
</span>
}
infoGrid={[
<InfoField key="amount" label="금액" value={formatCurrency(item.amount)} />,
<InfoField key="mileage" label="주행거리" value={item.mileage ? formatDistance(item.mileage) : '-'} />,
<InfoField key="vendor" label="업체" value={item.vendor || '-'} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleEdit(item)}
/>
);
},
[handleEdit]
);
return (
<>
<IntegratedListTemplateV2<VehicleMaintenance>
title="정비이력"
description="Vehicle Maintenance History"
icon={Wrench}
// 검색
searchValue={search}
onSearchChange={(v) => { setSearch(v); setCurrentPage(1); }}
searchPlaceholder="내용, 업체 검색..."
// 날짜 범위
dateRangeSelector={{
enabled: true,
showPresets: true,
presets: ['thisYear', 'lastMonth', 'thisMonth'],
startDate,
endDate,
onStartDateChange: (v) => { setStartDate(v); setCurrentPage(1); },
onEndDateChange: (v) => { setEndDate(v); setCurrentPage(1); },
}}
// 헤더 액션 (엑셀 다운로드)
headerActions={(
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
</Button>
)}
// 등록 버튼
createButton={{ label: '비용 등록', onClick: handleCreate }}
// 통계
stats={stats}
// 통합 필터 (PC: 인라인, 모바일: 바텀시트)
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="비용 필터"
// 컬럼
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage,
totalPages,
totalItems,
itemsPerPage: PAGE_SIZE,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
<MaintenanceFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
maintenance={selectedItem}
vehicles={vehicles}
onSuccess={fetchData}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
description="이 비용을 삭제하시겠습니까?"
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,442 @@
/**
* 차량관리 공통 타입 정의
* DB 마이그레이션 스키마 기반 (corporate_vehicles, vehicle_logs, vehicle_maintenances)
*/
// ===== 차량 목록 (Corporate Vehicles) =====
export type OwnershipType = 'corporate' | 'rent' | 'lease';
export type VehicleStatus = 'active' | 'maintenance' | 'disposed';
export type VehicleType = '승용차' | '승합차' | '화물차' | 'SUV';
export interface CorporateVehicle {
id: number;
plateNumber: string;
model: string;
vehicleType: VehicleType;
ownershipType: OwnershipType;
year: number | null;
driver: string | null;
status: VehicleStatus;
mileage: number;
memo: string | null;
// 법인 전용
purchaseDate: string | null;
purchasePrice: number;
// 렌트/리스 전용
contractDate: string | null;
rentCompany: string | null;
rentCompanyTel: string | null;
rentPeriod: string | null;
agreedMileage: string | null;
vehiclePrice: number;
residualValue: number;
deposit: number;
monthlyRent: number;
monthlyRentTax: number;
insuranceCompany: string | null;
insuranceCompanyTel: string | null;
createdAt: string;
updatedAt: string;
}
// API 응답 (snake_case)
export interface CorporateVehicleApi {
id: number;
plate_number: string;
model: string;
vehicle_type: string;
ownership_type: string;
year: number | null;
driver: string | null;
status: string;
mileage: number;
memo: string | null;
purchase_date: string | null;
purchase_price: number;
contract_date: string | null;
rent_company: string | null;
rent_company_tel: string | null;
rent_period: string | null;
agreed_mileage: string | null;
vehicle_price: number;
residual_value: number;
deposit: number;
monthly_rent: number;
monthly_rent_tax: number;
insurance_company: string | null;
insurance_company_tel: string | null;
created_at: string;
updated_at: string;
}
export function transformVehicleApi(api: CorporateVehicleApi): CorporateVehicle {
return {
id: api.id,
plateNumber: api.plate_number,
model: api.model,
vehicleType: api.vehicle_type as VehicleType,
ownershipType: api.ownership_type as OwnershipType,
year: api.year,
driver: api.driver,
status: api.status as VehicleStatus,
mileage: api.mileage,
memo: api.memo,
purchaseDate: api.purchase_date,
purchasePrice: api.purchase_price,
contractDate: api.contract_date,
rentCompany: api.rent_company,
rentCompanyTel: api.rent_company_tel,
rentPeriod: api.rent_period,
agreedMileage: api.agreed_mileage,
vehiclePrice: api.vehicle_price,
residualValue: api.residual_value,
deposit: api.deposit,
monthlyRent: api.monthly_rent,
monthlyRentTax: api.monthly_rent_tax,
insuranceCompany: api.insurance_company,
insuranceCompanyTel: api.insurance_company_tel,
createdAt: api.created_at,
updatedAt: api.updated_at,
};
}
export interface VehicleFormData {
plateNumber: string;
vehicleType: VehicleType | '';
ownershipType: OwnershipType | '';
model: string;
year: string;
// 법인: 취득일, 렌트/리스: 계약일자
purchaseDate: string;
contractDate: string;
// 법인: 구매처, 렌트/리스: 렌트회사명
rentCompany: string;
// 법인: 계약기간, 렌트/리스: 렌트기간
rentPeriod: string;
// 법인: 취득가(공급가), 렌트/리스: 월 렌트료(공급가)
purchasePrice: string;
monthlyRent: string;
monthlyRentTax: string;
rentCompanyTel: string;
agreedMileage: string;
vehiclePrice: string;
residualValue: string;
deposit: string;
mileage: string;
insuranceCompany: string;
insuranceCompanyTel: string;
driver: string;
status: VehicleStatus | '';
memo: string;
}
export const EMPTY_VEHICLE_FORM: VehicleFormData = {
plateNumber: '',
vehicleType: '',
ownershipType: '',
model: '',
year: '',
purchaseDate: '',
contractDate: '',
rentCompany: '',
rentPeriod: '',
purchasePrice: '',
monthlyRent: '',
monthlyRentTax: '',
rentCompanyTel: '',
agreedMileage: '',
vehiclePrice: '',
residualValue: '',
deposit: '',
mileage: '',
insuranceCompany: '',
insuranceCompanyTel: '',
driver: '',
status: '',
memo: '',
};
// 드롭다운용 차량 목록
export interface VehicleDropdownItem {
id: number;
plateNumber: string;
model: string;
}
interface VehicleDropdownApi {
id: number;
plate_number: string;
model: string;
}
export function transformVehicleDropdown(api: VehicleDropdownApi): VehicleDropdownItem {
return {
id: api.id,
plateNumber: api.plate_number,
model: api.model,
};
}
// ===== 차량일지 (Vehicle Logs) =====
export type TripType =
| 'commute_to'
| 'commute_from'
| 'business'
| 'personal'
| 'commute_round'
| 'business_round'
| 'personal_round';
export type LocationType = 'home' | 'office' | 'client' | 'other';
export const TRIP_TYPE_LABELS: Record<TripType, string> = {
commute_to: '출근',
commute_from: '퇴근',
business: '업무용',
personal: '비업무',
commute_round: '출퇴근(왕복)',
business_round: '업무(왕복)',
personal_round: '비업무(왕복)',
};
export const TRIP_TYPE_COLORS: Record<TripType, string> = {
commute_to: 'bg-green-100 text-green-700',
commute_from: 'bg-green-100 text-green-700',
business: 'bg-blue-100 text-blue-700',
personal: 'bg-gray-100 text-gray-700',
commute_round: 'bg-green-100 text-green-700',
business_round: 'bg-blue-100 text-blue-700',
personal_round: 'bg-gray-100 text-gray-700',
};
export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
home: '자택',
office: '회사',
client: '거래처',
other: '기타',
};
export interface VehicleLog {
id: number;
vehicleId: number;
logDate: string;
department: string | null;
driverName: string;
tripType: TripType;
departureType: LocationType;
departureName: string | null;
departureAddress: string | null;
arrivalType: LocationType;
arrivalName: string | null;
arrivalAddress: string | null;
distanceKm: number;
note: string | null;
// joined
vehicle?: VehicleDropdownItem;
}
export interface VehicleLogApi {
id: number;
vehicle_id: number;
log_date: string;
department: string | null;
driver_name: string;
trip_type: string;
departure_type: string;
departure_name: string | null;
departure_address: string | null;
arrival_type: string;
arrival_name: string | null;
arrival_address: string | null;
distance_km: number;
note: string | null;
vehicle?: VehicleDropdownApi;
}
export function transformVehicleLogApi(api: VehicleLogApi): VehicleLog {
return {
id: api.id,
vehicleId: api.vehicle_id,
logDate: api.log_date,
department: api.department,
driverName: api.driver_name,
tripType: api.trip_type as TripType,
departureType: api.departure_type as LocationType,
departureName: api.departure_name,
departureAddress: api.departure_address,
arrivalType: api.arrival_type as LocationType,
arrivalName: api.arrival_name,
arrivalAddress: api.arrival_address,
distanceKm: api.distance_km,
note: api.note,
vehicle: api.vehicle ? transformVehicleDropdown(api.vehicle) : undefined,
};
}
export interface VehicleLogFormData {
vehicleId: string;
logDate: string;
department: string;
driverName: string;
tripType: TripType | '';
departureType: LocationType | '';
departureName: string;
departureAddress: string;
arrivalType: LocationType | '';
arrivalName: string;
arrivalAddress: string;
distanceKm: string;
note: string;
}
export const EMPTY_LOG_FORM: VehicleLogFormData = {
vehicleId: '',
logDate: new Date().toISOString().slice(0, 10),
department: '',
driverName: '',
tripType: '',
departureType: '',
departureName: '',
departureAddress: '',
arrivalType: '',
arrivalName: '',
arrivalAddress: '',
distanceKm: '',
note: '',
};
export const NOTE_PRESETS = ['거래처방문', '제조시설등', '회의참석', '판촉활동', '교육등'];
export interface VehicleLogSummary {
totalDistance: number;
totalCount: number;
commuteToDistance: number;
commuteToCount: number;
commuteFromDistance: number;
commuteFromCount: number;
businessDistance: number;
businessCount: number;
personalDistance: number;
personalCount: number;
}
// ===== 정비이력 (Vehicle Maintenance) =====
export type MaintenanceCategory = '주유' | '정비' | '보험' | '세차' | '주차' | '통행료' | '검사' | '기타';
export const MAINTENANCE_CATEGORIES: MaintenanceCategory[] = [
'주유', '정비', '보험', '세차', '주차', '통행료', '검사', '기타',
];
export const CATEGORY_COLORS: Record<MaintenanceCategory, string> = {
'주유': 'bg-amber-100 text-amber-700',
'정비': 'bg-blue-100 text-blue-700',
'보험': 'bg-emerald-100 text-emerald-700',
'세차': 'bg-cyan-100 text-cyan-700',
'주차': 'bg-purple-100 text-purple-700',
'통행료': 'bg-orange-100 text-orange-700',
'검사': 'bg-indigo-100 text-indigo-700',
'기타': 'bg-gray-100 text-gray-700',
};
export interface VehicleMaintenance {
id: number;
vehicleId: number;
date: string;
category: MaintenanceCategory;
description: string;
amount: number;
mileage: number;
vendor: string | null;
memo: string | null;
// joined
vehicle?: VehicleDropdownItem;
}
export interface VehicleMaintenanceApi {
id: number;
vehicle_id: number;
date: string;
category: string;
description: string;
amount: number;
mileage: number;
vendor: string | null;
memo: string | null;
vehicle?: { id: number; plate_number: string; model: string };
}
export function transformMaintenanceApi(api: VehicleMaintenanceApi): VehicleMaintenance {
return {
id: api.id,
vehicleId: api.vehicle_id,
date: api.date,
category: api.category as MaintenanceCategory,
description: api.description,
amount: api.amount,
mileage: api.mileage,
vendor: api.vendor,
memo: api.memo,
vehicle: api.vehicle ? transformVehicleDropdown(api.vehicle) : undefined,
};
}
export interface MaintenanceFormData {
vehicleId: string;
date: string;
category: MaintenanceCategory | '';
description: string;
amount: string;
mileage: string;
vendor: string;
memo: string;
}
export const EMPTY_MAINTENANCE_FORM: MaintenanceFormData = {
vehicleId: '',
date: new Date().toISOString().slice(0, 10),
category: '',
description: '',
amount: '',
mileage: '',
vendor: '',
memo: '',
};
// ===== 공통 유틸 =====
export const OWNERSHIP_LABELS: Record<OwnershipType, string> = {
corporate: '법인차량',
rent: '렌트차량',
lease: '리스차량',
};
export const OWNERSHIP_COLORS: Record<OwnershipType, string> = {
corporate: 'bg-purple-100 text-purple-700',
rent: 'bg-blue-100 text-blue-700',
lease: 'bg-green-100 text-green-700',
};
export const STATUS_LABELS: Record<VehicleStatus, string> = {
active: '운행중',
maintenance: '정비중',
disposed: '처분',
};
export const STATUS_COLORS: Record<VehicleStatus, string> = {
active: 'bg-green-100 text-green-700',
maintenance: 'bg-yellow-100 text-yellow-700',
disposed: 'bg-red-100 text-red-700',
};
export const VEHICLE_TYPES: VehicleType[] = ['승용차', '승합차', '화물차', 'SUV'];
export function formatCurrency(value: number): string {
return value.toLocaleString('ko-KR') + '원';
}
export function formatDistance(value: number): string {
return value.toLocaleString('ko-KR') + 'km';
}