diff --git a/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md b/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md new file mode 100644 index 00000000..f6c04dca --- /dev/null +++ b/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report-v2.md @@ -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 = { + 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 && ( + +)} +``` + +--- + +## 이슈 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) 검사 완료 시? + - 합격률 기준? + - 수동 최종 승인 필요? diff --git a/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md b/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md new file mode 100644 index 00000000..51a3a764 --- /dev/null +++ b/claudedocs/architecture/[ANALYSIS-2026-03-13] mes-data-integrity-report.md @@ -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 = { + scheduled: 'ready', + ready: 'shipping', + shipping: 'completed', + completed: null, +}; + +// can_ship=false여도 버튼이 표시됨 ❌ +{STATUS_TRANSITIONS[detail.status] && ( + +)} +``` + +### 위험 시나리오 + +``` +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 삭제) diff --git a/claudedocs/dev/[REF] all-pages-test-urls.md b/claudedocs/dev/[REF] all-pages-test-urls.md index b6203559..3bf4a430 100644 --- a/claudedocs/dev/[REF] all-pages-test-urls.md +++ b/claudedocs/dev/[REF] all-pages-test-urls.md @@ -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) diff --git a/claudedocs/guides/[GUIDE] common-page-patterns.md b/claudedocs/guides/[GUIDE] common-page-patterns.md index b2cabba0..67ac5542 100644 --- a/claudedocs/guides/[GUIDE] common-page-patterns.md +++ b/claudedocs/guides/[GUIDE] common-page-patterns.md @@ -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 = ( -
- - -
-); +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(() => ( + +), [handleExcelDownload]); + +// 4️⃣ 템플릿에 전달 ``` +| prop | 역할 | 필수 | +|------|------|:----:| +| `filterConfig` | 필터 필드 정의 (key, label, type, options) | ✅ | +| `filterValues` | 현재 필터 상태 | ✅ | +| `onFilterChange` | 필터 값 변경 핸들러 | ✅ | +| `onFilterReset` | 필터 초기화 핸들러 | ✅ | +| `filterTitle` | 모바일 바텀시트 타이틀 (기본: "검색 필터") | 권장 | +| `tableHeaderActions` | 필터 외 액션 (엑셀 버튼 등) | 필요 시 | + ### 모바일 카드 (renderMobileCard) ```tsx diff --git a/sam-docs/frontend/v1/00-overview.md b/sam-docs/frontend/v1/00-overview.md new file mode 100644 index 00000000..1b6c66cf --- /dev/null +++ b/sam-docs/frontend/v1/00-overview.md @@ -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/*` | 큰 기능/실험적 작업 | diff --git a/sam-docs/frontend/v1/01-project-structure.md b/sam-docs/frontend/v1/01-project-structure.md new file mode 100644 index 00000000..728e52f8 --- /dev/null +++ b/sam-docs/frontend/v1/01-project-structure.md @@ -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/`)로 해결 가능한지 확인 diff --git a/sam-docs/frontend/v1/02-routing-and-pages.md b/sam-docs/frontend/v1/02-routing-and-pages.md new file mode 100644 index 00000000..7a51b29b --- /dev/null +++ b/sam-docs/frontend/v1/02-routing-and-pages.md @@ -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 ; + } + + // 기본 → 목록 + return ; +} +``` + +### 상세 + 수정 ([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 ; +} +``` + +### 네비게이션 + +```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`의 `
`에는 패딩 없음 +- `PageLayout` 컴포넌트가 `p-3 md:p-6` 패딩 담당 +- **page.tsx에서 패딩 wrapper 추가 금지** (이중 패딩 방지) + +### 등록/수정/상세 페이지 헤더 + +```tsx +
+

페이지 제목

+ +
+``` + +### 하단 Sticky 액션 바 (필수) + +폼 페이지 하단에 sticky bar로 버튼 배치: + +| 모드 | 좌측 | 우측 | +|------|------|------| +| 등록 (new) | `X 취소` | `💾 저장` | +| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` | +| 수정 (edit) | `X 취소` | `💾 저장` | + +```tsx +
+
+ + +
+
+``` + +## 테이블 표준 + +### 필수 컬럼 구조 +**체크박스** → **번호(1부터)** → **데이터 컬럼** → **작업 컬럼** + +```tsx +// 번호 계산 (페이지네이션 고려) +const globalIndex = (currentPage - 1) * pageSize + index + 1; +``` + +### 작업 버튼 +- 체크박스 선택 시에만 표시 + +## i18n + +``` +지원 언어: ko (기본), en, ja +경로: /ko/..., /en/..., /ja/... +``` diff --git a/sam-docs/frontend/v1/03-authentication.md b/sam-docs/frontend/v1/03-authentication.md new file mode 100644 index 00000000..7a521450 --- /dev/null +++ b/sam-docs/frontend/v1/03-authentication.md @@ -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 ( + + {/* 401 에러 자동 처리 */} + {/* 푸시 알림 */} + + {/* 권한 기반 접근 제어 */} + {children} + + + + + + ); +} +``` + +### 인증 상태 확인 (클라이언트) + +```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 또는 프록시 사용 diff --git a/sam-docs/frontend/v1/04-server-actions.md b/sam-docs/frontend/v1/04-server-actions.md new file mode 100644 index 00000000..6b41606c --- /dev/null +++ b/sam-docs/frontend/v1/04-server-actions.md @@ -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({ + url: buildApiUrl('/api/v1/items', { search: params.search }), + method: 'GET', // 기본값: GET + transform: (data) => ..., // snake_case → camelCase 변환 + errorMessage: '조회에 실패했습니다.', +}); + +// 반환 타입 +interface ActionResult { + success: boolean; + data?: T; + error?: string; + fieldErrors?: Record; // Laravel validation errors + __authError?: boolean; // 401 감지 +} +``` + +### executePaginatedAction - 페이지네이션 조회 + +```typescript +import { executePaginatedAction } from '@/lib/api/execute-paginated-action'; + +const result = await executePaginatedAction({ + url: buildApiUrl('/api/v1/items', { + search: params.search, + status: params.status !== 'all' ? params.status : undefined, + page: params.page, + }), + transform: transformApiToFrontend, // 개별 아이템 변환 함수 + errorMessage: '목록 조회에 실패했습니다.', +}); + +// 반환 타입 +interface PaginatedActionResult { + 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) { + 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) { + 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([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getItems({ page: 1 }) + .then(result => { + if (result.success) { + setData(result.data); + } + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) return
로딩 중...
; + 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 변환 (개발자 작성) diff --git a/sam-docs/frontend/v1/05-common-components.md b/sam-docs/frontend/v1/05-common-components.md new file mode 100644 index 00000000..b6110013 --- /dev/null +++ b/sam-docs/frontend/v1/05-common-components.md @@ -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`) + +**필수 적용 항목**: +1. 컬럼 설정 (`useColumnSettings` + `ColumnSettingsPopover`) +2. 모바일 카드 (`renderMobileCard`) +3. 체크박스 (`selectedItems: Set`) +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>(new Set()); + const { visibleColumns, allColumnsWithVisibility, columnWidths, setColumnWidth, + toggleColumnVisibility, resetSettings, hasHiddenColumns } = + useColumnSettings({ pageId: 'item-list', columns }); + + return ( + + ), + }} + 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) => ( + + + {globalIndex} + {item.itemName} + {item.itemCode} + + )} + // 모바일 카드 (반응형) + renderMobileCard={(item, index, globalIndex, isSelected, onToggle) => ( + + )} + // 페이지네이션 + 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 +등록} +/> +``` + +| Prop | 타입 | 설명 | +|------|------|------| +| `title` | string \| ReactNode | 페이지 제목 (필수) | +| `description?` | string | 부제목 | +| `icon?` | LucideIcon | 좌측 아이콘 | +| `actions?` | ReactNode | 우측 액션 버튼 | + +### PageLayout + +```tsx + + {children} + +``` + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `maxWidth?` | "sm"\|"md"\|"lg"\|"xl"\|"2xl"\|"full" | "full" | 최대 너비 | + +### StatCards + +```tsx + +``` + +### SearchFilter + +```tsx +} +/> +``` + +### DataTable + +```tsx + }, + ]} + 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 + + 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) => ( +
+
{vendor.name}
+
{vendor.code}
+
+ )} +/> +``` + +| Prop | 필수 | 설명 | +|------|:---:|------| +| `open` | O | 모달 열기 상태 | +| `onOpenChange` | O | 상태 변경 | +| `title` | O | 모달 제목 | +| `fetchData` | O | `(query: string) => Promise` | +| `keyExtractor` | O | `(item: T) => string` | +| `mode` | O | `'single'` \| `'multiple'` | +| `onSelect` | O | 선택 콜백 | +| `renderItem` | O | 아이템 렌더링 | +| `searchMode?` | | `'debounce'`(기본) \| `'enter'` | +| `loadOnOpen?` | | 열릴 때 자동 로드 | +| `listWrapper?` | | 리스트 래퍼 (테이블 구조 등) | + +### MobileCard / InfoField + +```tsx + router.push(`/items/${item.id}`)} +/> +``` + +### EmptyState / TableEmptyState + +```tsx + + +``` + +--- + +## Molecules + +**경로**: `src/components/molecules/` + +### FormField (신규 폼 필수) + +`Label + Input + Error` 수동 조합 대신 사용. + +```tsx +import { FormField } from '@/components/molecules/FormField'; + + 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'; + + + + +``` + +**variant**: `default`, `success`, `warning`, `danger`, `info`, `secondary`, `outline` + +### ColumnSettingsPopover + +`useColumnSettings` hook과 함께 사용: + +```tsx + +``` + +### StandardDialog + +```tsx + + + + + } +> +

이 작업은 되돌릴 수 없습니다.

+
+``` + +**size**: `sm`, `md`, `lg`, `xl`, `full` diff --git a/sam-docs/frontend/v1/06-ui-components.md b/sam-docs/frontend/v1/06-ui-components.md new file mode 100644 index 00000000..be103c59 --- /dev/null +++ b/sam-docs/frontend/v1/06-ui-components.md @@ -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 사용법 + +프로젝트 전체에서 `` 대신 사용. + +```tsx +import { DatePicker } from '@/components/ui/date-picker'; + +// 기본 + setDate(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으로 강제 리마운트 + +``` + +--- + +## 팝업 정책 + +``` +❌ 금지: alert(), confirm(), prompt() +✅ 사용: AlertDialog, ConfirmDialog, toast (sonner) +``` + +```tsx +// 토스트 +import { toast } from 'sonner'; +toast.success('저장되었습니다'); +toast.error('오류가 발생했습니다'); + +// 확인 다이얼로그 + + + + 삭제 확인 + 정말 삭제하시겠습니까? + + + 취소 + 삭제 + + + +``` diff --git a/sam-docs/frontend/v1/07-hooks.md b/sam-docs/frontend/v1/07-hooks.md new file mode 100644 index 00000000..9824f3c0 --- /dev/null +++ b/sam-docs/frontend/v1/07-hooks.md @@ -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
수정 권한이 없습니다.
; +} +``` + +### useDaumPostcode + +다음 우편번호 API 연동. + +```tsx +import { useDaumPostcode } from '@/hooks/useDaumPostcode'; + +const { openPostcode } = useDaumPostcode({ + onComplete: (data) => { + setAddress(data.address); + setZipCode(data.zonecode); + }, +}); +``` diff --git a/sam-docs/frontend/v1/08-utilities.md b/sam-docs/frontend/v1/08-utilities.md new file mode 100644 index 00000000..bca47666 --- /dev/null +++ b/sam-docs/frontend/v1/08-utilities.md @@ -0,0 +1,175 @@ +# 유틸리티 함수 + +--- + +## cn - 클래스명 병합 + +```typescript +import { cn } from '@/lib/utils'; + +// clsx + tailwind-merge +
+``` + +## safeJsonParse - 안전한 JSON 파싱 + +```typescript +import { safeJsonParse } from '@/lib/utils'; + +const data = safeJsonParse(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 +
+ {/* 인쇄될 영역 */} +
+``` + +--- + +## 인증 헤더 + +**경로**: `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; +}); +``` diff --git a/sam-docs/frontend/v1/09-coding-conventions.md b/sam-docs/frontend/v1/09-coding-conventions.md new file mode 100644 index 00000000..c41171e1 --- /dev/null +++ b/sam-docs/frontend/v1/09-coding-conventions.md @@ -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
로딩 중...
; + return ; +} +``` + +--- + +## 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 사용 + + +// ❌ 신규 폼에서 수동 조합 금지 +
+ + +
+``` + +**기존 폼**: 건드리지 않음 (정상 작동 중이면 마이그레이션 불필요) + +--- + +## 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; + +// 3. useForm에 연결 +const form = useForm({ + 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 +``` + +--- + +## 리스트 페이지 필수 항목 + +`IntegratedListTemplateV2` 사용 시: +- [ ] `useColumnSettings` + `ColumnSettingsPopover` 적용 +- [ ] `renderMobileCard` (모바일 카드) 구현 +- [ ] `selectedItems: Set` (체크박스) 구현 +- [ ] `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에 있으면 → +// covered에 있으면 → skip (렌더링 안 함) +// 둘 다 아니면 → 일반 +``` + +--- + +## 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`) diff --git a/src/app/[locale]/(protected)/vehicle/corporate-vehicles/page.tsx b/src/app/[locale]/(protected)/vehicle/corporate-vehicles/page.tsx new file mode 100644 index 00000000..8cff8e5d --- /dev/null +++ b/src/app/[locale]/(protected)/vehicle/corporate-vehicles/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/app/[locale]/(protected)/vehicle/vehicle-logs/page.tsx b/src/app/[locale]/(protected)/vehicle/vehicle-logs/page.tsx new file mode 100644 index 00000000..23972a42 --- /dev/null +++ b/src/app/[locale]/(protected)/vehicle/vehicle-logs/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/app/[locale]/(protected)/vehicle/vehicle-maintenance/page.tsx b/src/app/[locale]/(protected)/vehicle/vehicle-maintenance/page.tsx new file mode 100644 index 00000000..062daf5e --- /dev/null +++ b/src/app/[locale]/(protected)/vehicle/vehicle-maintenance/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/src/components/vehicle/CorporateVehicles/VehicleFormDialog.tsx b/src/components/vehicle/CorporateVehicles/VehicleFormDialog.tsx new file mode 100644 index 00000000..8d4afda1 --- /dev/null +++ b/src/components/vehicle/CorporateVehicles/VehicleFormDialog.tsx @@ -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(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( + (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 ( + <> + + + + {isEdit ? '차량 수정' : '차량 등록'} + + +
+ {/* Row 1: 차량번호, 종류, 구분 */} +
+
+ + updateField('plateNumber', e.target.value)} + placeholder="12가 3456" + /> +
+
+ + +
+
+ + +
+
+ + {/* Row 2: 모델 */} +
+ + updateField('model', e.target.value)} + placeholder="차량 모델명" + /> +
+ + {/* Row 3: 연식, 취득일/계약일자 */} +
+
+ + updateField('year', e.target.value)} + placeholder="2026" + /> +
+
+ + {isCorporate ? ( + updateField('purchaseDate', v)} + placeholder="연도. 월. 일." + /> + ) : ( + updateField('contractDate', v)} + placeholder="연도. 월. 일." + /> + )} +
+
+ + {/* Row 4: 구매처/렌트회사명, 계약기간/렌트기간 */} +
+
+ + updateField('rentCompany', e.target.value)} + placeholder={isCorporate ? '회사명' : '렌트회사명'} + /> +
+
+ + updateField('rentPeriod', e.target.value)} + placeholder="예: 36개월" + /> +
+
+ + {/* Row 5: 취득가(공급가)/월렌트료(공급가), 세액 */} +
+
+ + {isCorporate ? ( + updateField('purchasePrice', e.target.value)} + placeholder="0" + /> + ) : ( + updateField('monthlyRent', e.target.value)} + placeholder="0" + /> + )} +
+
+ + updateField('monthlyRentTax', e.target.value)} + placeholder="0" + /> +
+
+ + {/* Row 6: 회사 연락처, 약정운행거리 */} +
+
+ + updateField('rentCompanyTel', e.target.value)} + placeholder="연락처" + /> +
+
+ + updateField('agreedMileage', e.target.value)} + placeholder="km" + /> +
+
+ + {/* Row 7: 차량가격, 추정잔존가액 */} +
+
+ + updateField('vehiclePrice', e.target.value)} + placeholder="0" + /> +
+
+ + updateField('residualValue', e.target.value)} + placeholder="0" + /> +
+
+ + {/* Row 8: 보증금, 최초 주행거리 */} +
+
+ + updateField('deposit', e.target.value)} + placeholder="0" + /> +
+
+ + updateField('mileage', e.target.value)} + placeholder="0" + /> +
+
+ + {/* Row 9: 보험사명, 보험사 연락처 */} +
+
+ + updateField('insuranceCompany', e.target.value)} + placeholder="보험사명" + /> +
+
+ + updateField('insuranceCompanyTel', e.target.value)} + placeholder="연락처" + /> +
+
+ + {/* Row 10: 운전자, 상태 */} +
+
+ + updateField('driver', e.target.value)} + placeholder="운전자명" + /> +
+
+ + +
+
+ + {/* Row 11: 메모 */} +
+ +