diff --git a/app/Http/Controllers/Barobill/EcardController.php b/app/Http/Controllers/Barobill/EcardController.php index e0c471e6..1a410f5f 100644 --- a/app/Http/Controllers/Barobill/EcardController.php +++ b/app/Http/Controllers/Barobill/EcardController.php @@ -427,6 +427,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri $totalAmount = 0; $approvalCount = 0; $cancelCount = 0; + $totalTax = 0; foreach ($cardList as $card) { if (!is_object($card)) continue; @@ -484,6 +485,7 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri $totalAmount += $parsed['summary']['totalAmount']; $approvalCount += $parsed['summary']['approvalCount']; $cancelCount += $parsed['summary']['cancelCount']; + $totalTax += $parsed['summary']['totalTax'] ?? 0; } } } @@ -493,6 +495,24 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri return strcmp($b['useDt'] ?? '', $a['useDt'] ?? ''); }); + // 전체 데이터에서 공제/불공제 통계 계산 + $deductibleAmount = 0; + $deductibleCount = 0; + $nonDeductibleAmount = 0; + $nonDeductibleCount = 0; + + foreach ($allLogs as $log) { + $type = $log['deductionType'] ?? 'non_deductible'; + $amount = abs($log['approvalAmount'] ?? 0); + if ($type === 'deductible') { + $deductibleAmount += $amount; + $deductibleCount++; + } else { + $nonDeductibleAmount += $amount; + $nonDeductibleCount++; + } + } + // 페이지네이션 $totalCount = count($allLogs); $maxPageNum = (int)ceil($totalCount / $limit); @@ -513,7 +533,12 @@ private function getAllCardsTransactions(string $userId, string $startDate, stri 'totalAmount' => $totalAmount, 'count' => $totalCount, 'approvalCount' => $approvalCount, - 'cancelCount' => $cancelCount + 'cancelCount' => $cancelCount, + 'totalTax' => $totalTax, + 'deductibleAmount' => $deductibleAmount, + 'deductibleCount' => $deductibleCount, + 'nonDeductibleAmount' => $nonDeductibleAmount, + 'nonDeductibleCount' => $nonDeductibleCount, ] ] ]); @@ -528,6 +553,11 @@ private function parseTransactionLogs($resultData, $savedData = null): array $totalAmount = 0; $approvalCount = 0; $cancelCount = 0; + $totalTax = 0; + $deductibleAmount = 0; + $deductibleCount = 0; + $nonDeductibleAmount = 0; + $nonDeductibleCount = 0; $rawLogs = []; if (isset($resultData->CardLogList) && isset($resultData->CardLogList->CardApprovalLog)) { @@ -626,6 +656,18 @@ private function parseTransactionLogs($resultData, $savedData = null): array 'isSaved' => $savedItem !== null, ]; + // 공제/불공제 통계 집계 + $deductionType = $logItem['deductionType']; + $absAmount = abs($amount); + $totalTax += abs(floatval($log->Tax ?? 0)); + if ($deductionType === 'deductible') { + $deductibleAmount += $absAmount; + $deductibleCount++; + } else { + $nonDeductibleAmount += $absAmount; + $nonDeductibleCount++; + } + $logs[] = $logItem; } @@ -635,7 +677,12 @@ private function parseTransactionLogs($resultData, $savedData = null): array 'totalAmount' => $totalAmount, 'count' => count($logs), 'approvalCount' => $approvalCount, - 'cancelCount' => $cancelCount + 'cancelCount' => $cancelCount, + 'totalTax' => $totalTax, + 'deductibleAmount' => $deductibleAmount, + 'deductibleCount' => $deductibleCount, + 'nonDeductibleAmount' => $nonDeductibleAmount, + 'nonDeductibleCount' => $nonDeductibleCount, ] ]; } diff --git a/app/Http/Controllers/Barobill/EtaxController.php b/app/Http/Controllers/Barobill/EtaxController.php index 8fc60f55..976f9b2e 100644 --- a/app/Http/Controllers/Barobill/EtaxController.php +++ b/app/Http/Controllers/Barobill/EtaxController.php @@ -339,6 +339,82 @@ public function sendToNts(Request $request): JsonResponse } } + /** + * 공급자 기초정보 조회 + */ + public function getSupplier(): JsonResponse + { + $tenantId = session('selected_tenant_id'); + if (!$tenantId) { + return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400); + } + + $member = BarobillMember::where('tenant_id', $tenantId)->first(); + if (!$member) { + return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404); + } + + return response()->json([ + 'success' => true, + 'supplier' => [ + 'bizno' => $member->biz_no, + 'name' => $member->corp_name ?? '', + 'ceo' => $member->ceo_name ?? '', + 'addr' => $member->addr ?? '', + 'bizType' => $member->biz_type ?? '', + 'bizClass' => $member->biz_class ?? '', + 'contact' => $member->manager_name ?? '', + 'contactPhone' => $member->manager_hp ?? '', + 'email' => $member->manager_email ?? '', + ], + ]); + } + + /** + * 공급자 기초정보 수정 + */ + public function updateSupplier(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + if (!$tenantId) { + return response()->json(['success' => false, 'error' => '테넌트가 선택되지 않았습니다.'], 400); + } + + $member = BarobillMember::where('tenant_id', $tenantId)->first(); + if (!$member) { + return response()->json(['success' => false, 'error' => '바로빌 회원사 정보가 없습니다.'], 404); + } + + $validated = $request->validate([ + 'corp_name' => 'required|string|max:100', + 'ceo_name' => 'required|string|max:50', + 'addr' => 'nullable|string|max:255', + 'biz_type' => 'nullable|string|max:100', + 'biz_class' => 'nullable|string|max:100', + 'manager_name' => 'nullable|string|max:50', + 'manager_email' => 'nullable|email|max:100', + 'manager_hp' => 'nullable|string|max:20', + ]); + + $member->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '공급자 정보가 수정되었습니다.', + 'supplier' => [ + 'bizno' => $member->biz_no, + 'name' => $member->corp_name ?? '', + 'ceo' => $member->ceo_name ?? '', + 'addr' => $member->addr ?? '', + 'bizType' => $member->biz_type ?? '', + 'bizClass' => $member->biz_class ?? '', + 'contact' => $member->manager_name ?? '', + 'contactPhone' => $member->manager_hp ?? '', + 'email' => $member->manager_email ?? '', + ], + ]); + } + /** * 세금계산서 삭제 */ @@ -501,9 +577,17 @@ private function issueTaxInvoice(array $invoiceData): array 'TaxInvoiceTradeLineItems' => ['TaxInvoiceTradeLineItem' => []], ]; + $year = substr($invoiceData['supplyDate'] ?? date('Y-m-d'), 0, 4); + foreach ($invoiceData['items'] ?? [] as $item) { + $month = str_pad($item['month'] ?? '', 2, '0', STR_PAD_LEFT); + $day = str_pad($item['day'] ?? '', 2, '0', STR_PAD_LEFT); + $purchaseExpiry = ($month && $day && $month !== '00' && $day !== '00') + ? $year . $month . $day + : ''; + $taxInvoice['TaxInvoiceTradeLineItems']['TaxInvoiceTradeLineItem'][] = [ - 'PurchaseExpiry' => '', // 공제기한 + 'PurchaseExpiry' => $purchaseExpiry, // 거래일자 (YYYYMMDD) 'Name' => $item['name'] ?? '', // 품명 'Information' => $item['spec'] ?? '', // 규격 'ChargeableUnit' => $item['qty'] ?? '1', // 수량 diff --git a/app/Http/Controllers/Barobill/HometaxController.php b/app/Http/Controllers/Barobill/HometaxController.php index 474da07f..c445027d 100644 --- a/app/Http/Controllers/Barobill/HometaxController.php +++ b/app/Http/Controllers/Barobill/HometaxController.php @@ -6,6 +6,7 @@ use App\Models\Barobill\BarobillConfig; use App\Models\Barobill\BarobillMember; use App\Models\Tenants\Tenant; +use App\Services\Barobill\HometaxSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -1174,4 +1175,230 @@ private function xmlToObject(\SimpleXMLElement $xml): object return $result; } + + /** + * 로컬 DB에서 매출 세금계산서 조회 + */ + public function localSales(Request $request, HometaxSyncService $syncService): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $startDate = $request->input('startDate'); + $endDate = $request->input('endDate'); + + // YYYYMMDD 형식을 Y-m-d로 변환 + if (strlen($startDate) === 8) { + $startDate = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2); + } + if (strlen($endDate) === 8) { + $endDate = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2); + } + + $dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue'; + $searchCorp = $request->input('searchCorp'); + + $data = $syncService->getLocalInvoices( + $tenantId, + 'sales', + $startDate, + $endDate, + $dateType, + $searchCorp + ); + + return response()->json([ + 'success' => true, + 'data' => $data, + 'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'sales'), + ]); + } catch (\Throwable $e) { + Log::error('로컬 매출 조회 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '조회 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 로컬 DB에서 매입 세금계산서 조회 + */ + public function localPurchases(Request $request, HometaxSyncService $syncService): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + + $startDate = $request->input('startDate'); + $endDate = $request->input('endDate'); + + // YYYYMMDD 형식을 Y-m-d로 변환 + if (strlen($startDate) === 8) { + $startDate = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2); + } + if (strlen($endDate) === 8) { + $endDate = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2); + } + + $dateType = $request->input('dateType', 1) == 1 ? 'write' : 'issue'; + $searchCorp = $request->input('searchCorp'); + + $data = $syncService->getLocalInvoices( + $tenantId, + 'purchase', + $startDate, + $endDate, + $dateType, + $searchCorp + ); + + return response()->json([ + 'success' => true, + 'data' => $data, + 'lastSyncAt' => $syncService->getLastSyncTime($tenantId, 'purchase'), + ]); + } catch (\Throwable $e) { + Log::error('로컬 매입 조회 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '조회 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 바로빌 API에서 데이터를 가져와 로컬 DB에 동기화 + */ + public function sync(Request $request, HometaxSyncService $syncService): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $type = $request->input('type', 'all'); // 'sales', 'purchase', 'all' + $startDate = $request->input('startDate', date('Ymd', strtotime('-1 month'))); + $endDate = $request->input('endDate', date('Ymd')); + $dateType = (int)$request->input('dateType', 1); + + $results = []; + + // 매출 동기화 + if ($type === 'all' || $type === 'sales') { + $salesRequest = new Request([ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'dateType' => $dateType, + 'limit' => 500, + ]); + + $salesResponse = $this->sales($salesRequest); + $salesData = json_decode($salesResponse->getContent(), true); + + if ($salesData['success'] && !empty($salesData['data']['invoices'])) { + $results['sales'] = $syncService->syncInvoices( + $salesData['data']['invoices'], + $tenantId, + 'sales' + ); + } else { + $results['sales'] = [ + 'inserted' => 0, + 'updated' => 0, + 'failed' => 0, + 'total' => 0, + 'error' => $salesData['error'] ?? null, + ]; + } + } + + // 매입 동기화 + if ($type === 'all' || $type === 'purchase') { + $purchaseRequest = new Request([ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'dateType' => $dateType, + 'limit' => 500, + ]); + + $purchaseResponse = $this->purchases($purchaseRequest); + $purchaseData = json_decode($purchaseResponse->getContent(), true); + + if ($purchaseData['success'] && !empty($purchaseData['data']['invoices'])) { + $results['purchase'] = $syncService->syncInvoices( + $purchaseData['data']['invoices'], + $tenantId, + 'purchase' + ); + } else { + $results['purchase'] = [ + 'inserted' => 0, + 'updated' => 0, + 'failed' => 0, + 'total' => 0, + 'error' => $purchaseData['error'] ?? null, + ]; + } + } + + // 총 결과 계산 + $totalInserted = ($results['sales']['inserted'] ?? 0) + ($results['purchase']['inserted'] ?? 0); + $totalUpdated = ($results['sales']['updated'] ?? 0) + ($results['purchase']['updated'] ?? 0); + + return response()->json([ + 'success' => true, + 'message' => "동기화 완료: {$totalInserted}건 추가, {$totalUpdated}건 갱신", + 'data' => $results, + ]); + } catch (\Throwable $e) { + Log::error('홈택스 동기화 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '동기화 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 세금계산서 메모 업데이트 + */ + public function updateMemo(Request $request, HometaxSyncService $syncService): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $id = $request->input('id'); + $memo = $request->input('memo'); + + $success = $syncService->updateMemo($id, $tenantId, $memo); + + return response()->json([ + 'success' => $success, + 'message' => $success ? '메모가 저장되었습니다.' : '저장에 실패했습니다.', + ]); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'error' => '오류: ' . $e->getMessage() + ]); + } + } + + /** + * 세금계산서 확인 여부 토글 + */ + public function toggleChecked(Request $request, HometaxSyncService $syncService): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $id = $request->input('id'); + + $success = $syncService->toggleChecked($id, $tenantId); + + return response()->json([ + 'success' => $success, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'error' => '오류: ' . $e->getMessage() + ]); + } + } } diff --git a/app/Http/Controllers/Finance/VehicleLogController.php b/app/Http/Controllers/Finance/VehicleLogController.php index e32970dc..9c568b5e 100644 --- a/app/Http/Controllers/Finance/VehicleLogController.php +++ b/app/Http/Controllers/Finance/VehicleLogController.php @@ -9,9 +9,6 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\View\View; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Writer\Xlsx; -use Symfony\Component\HttpFoundation\StreamedResponse; class VehicleLogController extends Controller { @@ -21,82 +18,112 @@ public function index(Request $request): View|Response return response('', 200)->header('HX-Redirect', route('finance.vehicle-logs')); } + return view('finance.vehicle-logs'); + } + + /** + * 차량 목록 조회 + */ + public function vehicles(Request $request): JsonResponse + { $tenantId = session('tenant_id', 1); + $vehicles = CorporateVehicle::where('tenant_id', $tenantId) - ->where('status', 'active') ->orderBy('plate_number') ->get(); - return view('finance.vehicle-logs', [ - 'vehicles' => $vehicles, - 'tripTypes' => VehicleLog::tripTypeLabels(), - 'locationTypes' => VehicleLog::locationTypeLabels(), + return response()->json([ + 'success' => true, + 'data' => $vehicles, ]); } + /** + * 운행기록 목록 조회 + */ public function list(Request $request): JsonResponse { $tenantId = session('tenant_id', 1); - $request->validate([ - 'vehicle_id' => 'required|integer', - 'start_date' => 'required|date', - 'end_date' => 'required|date|after_or_equal:start_date', - ]); + $query = VehicleLog::with('vehicle') + ->where('tenant_id', $tenantId); - $vehicleId = $request->vehicle_id; - $startDate = $request->start_date; - $endDate = $request->end_date; + // 차량 필터 + if ($request->filled('vehicle_id') && $request->vehicle_id !== 'all') { + $query->where('vehicle_id', $request->vehicle_id); + } - // 차량 정보 - $vehicle = CorporateVehicle::where('tenant_id', $tenantId) - ->findOrFail($vehicleId); + // 년/월 필터 + if ($request->filled('year')) { + $query->whereYear('log_date', $request->year); + } + if ($request->filled('month')) { + $query->whereMonth('log_date', $request->month); + } - // 전체 운행기록 수 (해당 차량) - $totalCount = VehicleLog::where('tenant_id', $tenantId) - ->where('vehicle_id', $vehicleId) - ->count(); + // 구분 필터 + if ($request->filled('trip_type') && $request->trip_type !== 'all') { + $query->where('trip_type', $request->trip_type); + } - $logs = VehicleLog::where('tenant_id', $tenantId) - ->where('vehicle_id', $vehicleId) - ->whereBetween('log_date', [$startDate, $endDate]) - ->orderBy('log_date', 'desc') + // 검색어 + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('driver_name', 'like', "%{$search}%") + ->orWhere('department', 'like', "%{$search}%") + ->orWhere('departure_name', 'like', "%{$search}%") + ->orWhere('arrival_name', 'like', "%{$search}%") + ->orWhere('note', 'like', "%{$search}%"); + }); + } + + $logs = $query->orderBy('log_date', 'desc') ->orderBy('id', 'desc') ->get(); - // 월별 합계 - $totals = [ - 'business_km' => $logs->whereIn('trip_type', ['commute_to', 'commute_from', 'business', 'commute_round', 'business_round'])->sum('distance_km'), - 'personal_km' => $logs->whereIn('trip_type', ['personal', 'personal_round'])->sum('distance_km'), - 'total_km' => $logs->sum('distance_km'), - ]; + // 응답 포맷팅 + $data = $logs->map(function ($log) { + return [ + 'id' => $log->id, + 'logDate' => $log->log_date->format('Y-m-d'), + 'vehicleId' => $log->vehicle_id, + 'plateNumber' => $log->vehicle?->plate_number, + 'model' => $log->vehicle?->model, + 'department' => $log->department, + 'driverName' => $log->driver_name, + 'tripType' => $log->trip_type, + 'departureType' => $log->departure_type, + 'departureName' => $log->departure_name, + 'departureAddress' => $log->departure_address, + 'arrivalType' => $log->arrival_type, + 'arrivalName' => $log->arrival_name, + 'arrivalAddress' => $log->arrival_address, + 'distanceKm' => $log->distance_km, + 'note' => $log->note, + ]; + }); return response()->json([ 'success' => true, - 'data' => [ - 'vehicle' => $vehicle, - 'logs' => $logs, - 'totals' => $totals, - 'totalCount' => $totalCount, - ], + 'data' => $data, ]); } + /** + * 운행기록 등록 + */ public function store(Request $request): JsonResponse { - $tenantId = session('tenant_id', 1); - $request->validate([ - 'vehicle_id' => 'required|integer|exists:corporate_vehicles,id', + 'vehicle_id' => 'required|exists:corporate_vehicles,id', 'log_date' => 'required|date', 'driver_name' => 'required|string|max:50', - 'trip_type' => 'required|in:commute_to,commute_from,business,personal,commute_round,business_round,personal_round', + 'trip_type' => 'required|in:commute_to,commute_from,business,personal', 'distance_km' => 'required|integer|min:0', ]); - // 해당 차량이 현재 테넌트의 것인지 확인 - CorporateVehicle::where('tenant_id', $tenantId) - ->findOrFail($request->vehicle_id); + $tenantId = session('tenant_id', 1); $log = VehicleLog::create([ 'tenant_id' => $tenantId, @@ -122,6 +149,9 @@ public function store(Request $request): JsonResponse ]); } + /** + * 운행기록 수정 + */ public function update(Request $request, int $id): JsonResponse { $tenantId = session('tenant_id', 1); @@ -129,13 +159,15 @@ public function update(Request $request, int $id): JsonResponse $log = VehicleLog::where('tenant_id', $tenantId)->findOrFail($id); $request->validate([ + 'vehicle_id' => 'required|exists:corporate_vehicles,id', 'log_date' => 'required|date', 'driver_name' => 'required|string|max:50', - 'trip_type' => 'required|in:commute_to,commute_from,business,personal,commute_round,business_round,personal_round', + 'trip_type' => 'required|in:commute_to,commute_from,business,personal', 'distance_km' => 'required|integer|min:0', ]); $log->update([ + 'vehicle_id' => $request->vehicle_id, 'log_date' => $request->log_date, 'department' => $request->department, 'driver_name' => $request->driver_name, @@ -157,6 +189,9 @@ public function update(Request $request, int $id): JsonResponse ]); } + /** + * 운행기록 삭제 + */ public function destroy(int $id): JsonResponse { $tenantId = session('tenant_id', 1); @@ -170,96 +205,65 @@ public function destroy(int $id): JsonResponse ]); } - public function export(Request $request): StreamedResponse + /** + * 월간 통계 조회 + */ + public function summary(Request $request): JsonResponse { $tenantId = session('tenant_id', 1); - $request->validate([ - 'vehicle_id' => 'required|integer', - 'start_date' => 'required|date', - 'end_date' => 'required|date|after_or_equal:start_date', - ]); + $query = VehicleLog::where('tenant_id', $tenantId); - $vehicleId = $request->vehicle_id; - $startDate = $request->start_date; - $endDate = $request->end_date; - - $vehicle = CorporateVehicle::where('tenant_id', $tenantId) - ->findOrFail($vehicleId); - - $logs = VehicleLog::where('tenant_id', $tenantId) - ->where('vehicle_id', $vehicleId) - ->whereBetween('log_date', [$startDate, $endDate]) - ->orderBy('log_date') - ->orderBy('id') - ->get(); - - $spreadsheet = new Spreadsheet(); - $sheet = $spreadsheet->getActiveSheet(); - $sheet->setTitle('운행기록부'); - - // 기본 정보 - $sheet->setCellValue('A1', '업무용승용차 운행기록부'); - $sheet->setCellValue('A3', '차량번호'); - $sheet->setCellValue('B3', $vehicle->plate_number); - $sheet->setCellValue('C3', '차종'); - $sheet->setCellValue('D3', $vehicle->model); - $sheet->setCellValue('E3', '구분'); - $sheet->setCellValue('F3', $this->getOwnershipTypeLabel($vehicle->ownership_type)); - $sheet->setCellValue('A4', '조회기간'); - $sheet->setCellValue('B4', sprintf('%s ~ %s', $startDate, $endDate)); - - // 헤더 - $headers = ['일자', '부서', '성명', '구분', '출발지', '도착지', '주행km', '비고']; - $col = 'A'; - foreach ($headers as $header) { - $sheet->setCellValue($col . '6', $header); - $col++; + // 차량 필터 + if ($request->filled('vehicle_id') && $request->vehicle_id !== 'all') { + $query->where('vehicle_id', $request->vehicle_id); } - // 데이터 - $row = 7; - $tripTypeLabels = VehicleLog::tripTypeLabels(); - - foreach ($logs as $log) { - $sheet->setCellValue('A' . $row, $log->log_date->format('Y-m-d')); - $sheet->setCellValue('B' . $row, $log->department ?? ''); - $sheet->setCellValue('C' . $row, $log->driver_name); - $sheet->setCellValue('D' . $row, $tripTypeLabels[$log->trip_type] ?? $log->trip_type); - $sheet->setCellValue('E' . $row, $log->departure_name ?? ''); - $sheet->setCellValue('F' . $row, $log->arrival_name ?? ''); - $sheet->setCellValue('G' . $row, $log->distance_km); - $sheet->setCellValue('H' . $row, $log->note ?? ''); - $row++; + // 년/월 필터 + if ($request->filled('year')) { + $query->whereYear('log_date', $request->year); + } + if ($request->filled('month')) { + $query->whereMonth('log_date', $request->month); } - // 합계 - $businessKm = $logs->whereIn('trip_type', ['commute_to', 'commute_from', 'business', 'commute_round', 'business_round'])->sum('distance_km'); - $personalKm = $logs->whereIn('trip_type', ['personal', 'personal_round'])->sum('distance_km'); - $totalKm = $logs->sum('distance_km'); + // 구분별 주행거리 합계 + $summary = $query->selectRaw(' + trip_type, + COUNT(*) as count, + SUM(distance_km) as total_distance + ') + ->groupBy('trip_type') + ->get() + ->keyBy('trip_type'); - $sheet->setCellValue('A' . $row, '합계'); - $sheet->setCellValue('F' . $row, '업무용: ' . number_format($businessKm) . 'km'); - $sheet->setCellValue('G' . $row, number_format($totalKm)); - $sheet->setCellValue('H' . $row, '비업무: ' . number_format($personalKm) . 'km'); + $tripTypes = VehicleLog::getTripTypes(); + $result = []; + $totalCount = 0; + $totalDistance = 0; - $filename = sprintf('운행기록부_%s_%s_%s.xlsx', $vehicle->plate_number, $startDate, $endDate); + foreach ($tripTypes as $type => $label) { + $data = $summary->get($type); + $count = $data ? $data->count : 0; + $distance = $data ? $data->total_distance : 0; + $result[$type] = [ + 'label' => $label, + 'count' => $count, + 'distance' => $distance, + ]; + $totalCount += $count; + $totalDistance += $distance; + } - return response()->streamDownload(function () use ($spreadsheet) { - $writer = new Xlsx($spreadsheet); - $writer->save('php://output'); - }, $filename, [ - 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + return response()->json([ + 'success' => true, + 'data' => [ + 'byType' => $result, + 'total' => [ + 'count' => $totalCount, + 'distance' => $totalDistance, + ], + ], ]); } - - private function getOwnershipTypeLabel(string $type): string - { - return match ($type) { - 'corporate' => '회사', - 'rent' => '렌트', - 'lease' => '리스', - default => $type, - }; - } } diff --git a/app/Http/Controllers/Finance/VehicleMaintenanceController.php b/app/Http/Controllers/Finance/VehicleMaintenanceController.php new file mode 100644 index 00000000..12eb8802 --- /dev/null +++ b/app/Http/Controllers/Finance/VehicleMaintenanceController.php @@ -0,0 +1,200 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('finance.vehicle-maintenance')); + } + + return view('finance.vehicle-maintenance'); + } + + /** + * 차량 목록 조회 + */ + public function vehicles(Request $request): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $vehicles = CorporateVehicle::where('tenant_id', $tenantId) + ->orderBy('plate_number') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $vehicles, + ]); + } + + /** + * 정비 이력 목록 조회 + */ + public function list(Request $request): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $query = VehicleMaintenance::with('vehicle') + ->where('tenant_id', $tenantId); + + // 차량 필터 + if ($request->filled('vehicle_id') && $request->vehicle_id !== 'all') { + $query->where('vehicle_id', $request->vehicle_id); + } + + // 카테고리 필터 + if ($request->filled('category') && $request->category !== 'all') { + $query->where('category', $request->category); + } + + // 날짜 범위 필터 + if ($request->filled('start_date')) { + $query->whereDate('date', '>=', $request->start_date); + } + if ($request->filled('end_date')) { + $query->whereDate('date', '<=', $request->end_date); + } + + // 검색어 + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhere('vendor', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%"); + }); + } + + $maintenances = $query->orderBy('date', 'desc')->get(); + + // 응답 포맷팅 + $data = $maintenances->map(function ($m) { + return [ + 'id' => $m->id, + 'date' => $m->date->format('Y-m-d'), + 'vehicleId' => $m->vehicle_id, + 'plateNumber' => $m->vehicle?->plate_number, + 'model' => $m->vehicle?->model, + 'category' => $m->category, + 'description' => $m->description, + 'amount' => $m->amount, + 'mileage' => $m->mileage, + 'vendor' => $m->vendor, + 'memo' => $m->memo, + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => $data, + ]); + } + + /** + * 정비 이력 등록 + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'vehicle_id' => 'required|exists:corporate_vehicles,id', + 'date' => 'required|date', + 'category' => 'required|string|max:20', + 'amount' => 'required|numeric|min:0', + ]); + + $tenantId = session('tenant_id', 1); + + $maintenance = VehicleMaintenance::create([ + 'tenant_id' => $tenantId, + 'vehicle_id' => $request->vehicle_id, + 'date' => $request->date, + 'category' => $request->category, + 'description' => $request->description, + 'amount' => $request->amount ?? 0, + 'mileage' => $request->mileage, + 'vendor' => $request->vendor, + 'memo' => $request->memo, + ]); + + // 차량 주행거리 업데이트 + if ($request->filled('mileage')) { + CorporateVehicle::where('id', $request->vehicle_id) + ->where('tenant_id', $tenantId) + ->update(['mileage' => $request->mileage]); + } + + return response()->json([ + 'success' => true, + 'message' => '정비 이력이 등록되었습니다.', + 'data' => $maintenance, + ]); + } + + /** + * 정비 이력 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $maintenance = VehicleMaintenance::where('tenant_id', $tenantId)->findOrFail($id); + + $request->validate([ + 'vehicle_id' => 'required|exists:corporate_vehicles,id', + 'date' => 'required|date', + 'category' => 'required|string|max:20', + 'amount' => 'required|numeric|min:0', + ]); + + $maintenance->update([ + 'vehicle_id' => $request->vehicle_id, + 'date' => $request->date, + 'category' => $request->category, + 'description' => $request->description, + 'amount' => $request->amount ?? 0, + 'mileage' => $request->mileage, + 'vendor' => $request->vendor, + 'memo' => $request->memo, + ]); + + // 차량 주행거리 업데이트 + if ($request->filled('mileage')) { + CorporateVehicle::where('id', $request->vehicle_id) + ->where('tenant_id', $tenantId) + ->update(['mileage' => $request->mileage]); + } + + return response()->json([ + 'success' => true, + 'message' => '정비 이력이 수정되었습니다.', + 'data' => $maintenance, + ]); + } + + /** + * 정비 이력 삭제 + */ + public function destroy(int $id): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $maintenance = VehicleMaintenance::where('tenant_id', $tenantId)->findOrFail($id); + $maintenance->delete(); + + return response()->json([ + 'success' => true, + 'message' => '정비 이력이 삭제되었습니다.', + ]); + } +} diff --git a/app/Http/Controllers/Sales/AdminProspectController.php b/app/Http/Controllers/Sales/AdminProspectController.php index b2b38b0e..67ecc57d 100644 --- a/app/Http/Controllers/Sales/AdminProspectController.php +++ b/app/Http/Controllers/Sales/AdminProspectController.php @@ -108,8 +108,9 @@ private function getIndexData(Request $request): array }); } - // 상태 필터 - if (!empty($filters['status'])) { + // 상태 필터 (progress_complete는 계산값 기반이므로 별도 처리) + $isProgressCompleteFilter = ($filters['status'] === 'progress_complete'); + if (!empty($filters['status']) && !$isProgressCompleteFilter) { $query->where('status', $filters['status']); } @@ -118,32 +119,87 @@ private function getIndexData(Request $request): array $query->where('registered_by', $filters['registered_by']); } - $prospects = $query->orderByDesc('created_at')->paginate(20); + // progress_complete 필터: 전체 조회 후 PHP에서 필터링 + if ($isProgressCompleteFilter) { + $allProspects = $query->orderByDesc('created_at')->get(); - // 각 가망고객의 진행률 계산 및 상태 자동 전환 - foreach ($prospects as $prospect) { - $progress = SalesScenarioChecklist::getProspectProgress($prospect->id); - $prospect->sales_progress = $progress['sales']['percentage']; - $prospect->manager_progress = $progress['manager']['percentage']; + // 진행률 계산 및 부가정보 세팅 + foreach ($allProspects as $prospect) { + $progress = SalesScenarioChecklist::getProspectProgress($prospect->id); + $prospect->sales_progress = $progress['sales']['percentage']; + $prospect->manager_progress = $progress['manager']['percentage']; - // 진행률 100% 시 상태 자동 전환 체크 - if ($progress['sales']['percentage'] === 100 && $progress['manager']['percentage'] === 100) { - SalesScenarioChecklist::checkAndConvertProspectStatus($prospect->id); - $prospect->refresh(); + if ($progress['sales']['percentage'] === 100 && $progress['manager']['percentage'] === 100) { + SalesScenarioChecklist::checkAndConvertProspectStatus($prospect->id); + $prospect->refresh(); + } + + $management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first(); + $prospect->hq_status = $management?->hq_status ?? 'pending'; + $prospect->hq_status_label = $management?->hq_status_label ?? '대기'; + $prospect->manager_user = $management?->manager; + + if ($management) { + $commission = SalesCommission::where('management_id', $management->id)->first(); + $prospect->commission = $commission; + } else { + $prospect->commission = null; + } } - // management 정보 - $management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first(); - $prospect->hq_status = $management?->hq_status ?? 'pending'; - $prospect->hq_status_label = $management?->hq_status_label ?? '대기'; - $prospect->manager_user = $management?->manager; + // 두 시나리오 모두 100%인 것만 필터링 + $filtered = $allProspects->filter(function ($prospect) { + return $prospect->sales_progress === 100 && $prospect->manager_progress === 100; + }); - // 수당 정보 (management가 있는 경우) - if ($management) { - $commission = SalesCommission::where('management_id', $management->id)->first(); - $prospect->commission = $commission; - } else { - $prospect->commission = null; + // 수동 페이지네이션 + $page = request()->get('page', 1); + $perPage = 20; + $prospects = new \Illuminate\Pagination\LengthAwarePaginator( + $filtered->forPage($page, $perPage)->values(), + $filtered->count(), + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->query()] + ); + } else { + $prospects = $query->orderByDesc('created_at')->paginate(20); + + // 각 가망고객의 진행률 계산 및 상태 자동 전환 + foreach ($prospects as $prospect) { + $progress = SalesScenarioChecklist::getProspectProgress($prospect->id); + $prospect->sales_progress = $progress['sales']['percentage']; + $prospect->manager_progress = $progress['manager']['percentage']; + + // 진행률 100% 시 상태 자동 전환 체크 + if ($progress['sales']['percentage'] === 100 && $progress['manager']['percentage'] === 100) { + SalesScenarioChecklist::checkAndConvertProspectStatus($prospect->id); + $prospect->refresh(); + } + + // management 정보 + $management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first(); + $prospect->hq_status = $management?->hq_status ?? 'pending'; + $prospect->hq_status_label = $management?->hq_status_label ?? '대기'; + $prospect->manager_user = $management?->manager; + + // 수당 정보 (management가 있는 경우) + if ($management) { + $commission = SalesCommission::where('management_id', $management->id)->first(); + $prospect->commission = $commission; + } else { + $prospect->commission = null; + } + } + } + + // 진행완료 건수 계산 (전체 prospect 중 두 시나리오 모두 100%인 건수) + $progressCompleteCount = 0; + $allForStats = TenantProspect::all(); + foreach ($allForStats as $p) { + $prog = SalesScenarioChecklist::getProspectProgress($p->id); + if ($prog['sales']['percentage'] === 100 && $prog['manager']['percentage'] === 100) { + $progressCompleteCount++; } } @@ -153,6 +209,7 @@ private function getIndexData(Request $request): array 'active' => TenantProspect::where('status', TenantProspect::STATUS_ACTIVE)->count(), 'expired' => TenantProspect::where('status', TenantProspect::STATUS_EXPIRED)->count(), 'converted' => TenantProspect::where('status', TenantProspect::STATUS_CONVERTED)->count(), + 'progress_complete' => $progressCompleteCount, ]; // 영업파트너별 통계 diff --git a/app/Http/Controllers/Sales/SalesDashboardController.php b/app/Http/Controllers/Sales/SalesDashboardController.php index ed15843b..20dbe357 100644 --- a/app/Http/Controllers/Sales/SalesDashboardController.php +++ b/app/Http/Controllers/Sales/SalesDashboardController.php @@ -181,7 +181,8 @@ private function getDashboardData(Request $request): array $totalMembershipFee += $handoverTotalRegFee; $totalCommission = $partnerCommissionTotal + $managerCommissionTotal; - // 역할별 수당 업데이트 + // 역할별 수당 업데이트 (실제 지급된 수당 기준) + // 참고: 예상 수당은 나중에 $totalExpectedCommission으로 별도 계산됨 $commissionByRole[0]['amount'] = $partnerCommissionTotal; $commissionByRole[1]['amount'] = $managerCommissionTotal; @@ -294,6 +295,10 @@ private function getDashboardData(Request $request): array $paidCommission ); + // 역할별 수당을 예상 수당 기준으로 재설정 (1차+2차 수당과 일치하도록) + // 판매자 예상 수당 = 개발비 × 10% (개발 진행 중 + 인계 완료 미지급) + $commissionByRole[0]['amount'] = $totalExpectedCommission; + // 전환된 테넌트만 조회 (최신순, 페이지네이션) $tenants = Tenant::whereIn('id', $convertedTenantIds) ->orderBy('created_at', 'desc') @@ -653,11 +658,49 @@ private function calculatePartnerSummaryStats(array $partnerIds, int $currentUse // 최종 예상 수당 (확정 + 예상 중 큰 값) $expectedCommission = max($confirmedCommission, $expectedFromFee); + // 지급 완료된 매니저 수당 + $paidManagerCommission = SalesCommission::where('manager_user_id', $currentUserId) + ->whereHas('partner', function ($query) use ($partnerIds) { + $query->whereIn('user_id', $partnerIds); + }) + ->where('status', SalesCommission::STATUS_PAID) + ->sum('manager_commission'); + + // 지급예정 (승인됨) + $scheduledManagerCommission = SalesCommission::where('manager_user_id', $currentUserId) + ->whereHas('partner', function ($query) use ($partnerIds) { + $query->whereIn('user_id', $partnerIds); + }) + ->where('status', SalesCommission::STATUS_APPROVED) + ->sum('manager_commission'); + + // 입금대기 = 총 예상 - 지급완료 - 지급예정 + $pendingManagerCommission = max(0, $expectedCommission - $paidManagerCommission - $scheduledManagerCommission); + + // 1차/2차 분할 (각 50%) + $halfExpected = $expectedCommission / 2; + $halfPending = $pendingManagerCommission / 2; + $halfScheduled = $scheduledManagerCommission / 2; + $halfPaid = $paidManagerCommission / 2; + return [ 'partner_count' => $partnerCount, 'total_prospects' => $totalProspects, 'total_conversions' => $totalConversions, 'expected_commission' => $expectedCommission, + 'paid_commission' => $paidManagerCommission, + 'first_commission' => [ + 'total' => (int)$halfExpected, + 'pending' => (int)$halfPending, + 'scheduled' => (int)$halfScheduled, + 'paid' => (int)$halfPaid, + ], + 'second_commission' => [ + 'total' => (int)$halfExpected, + 'pending' => (int)$halfPending, + 'scheduled' => (int)$halfScheduled, + 'paid' => (int)$halfPaid, + ], ]; } @@ -891,6 +934,16 @@ public function helpGuide(): View return view('sales.dashboard.partials.help-modal', compact('htmlContent')); } + /** + * 가망고객 개별 행 반환 (HTMX 동적 업데이트용) + */ + public function getProspectRow(int $prospectId): View + { + $prospect = TenantProspect::findOrFail($prospectId); + + return view('sales.dashboard.partials.prospect-row', compact('prospect')); + } + /** * 예상 수당 요약 계산 (개발 진행중 + 인계완료 미지급) */ @@ -905,7 +958,7 @@ private function calculateExpectedCommissionSummary($commissions, int $totalExpe ->whereIn('status', [SalesCommission::STATUS_PENDING, SalesCommission::STATUS_APPROVED]) ->sum('partner_commission'); - // 납입대기 금액 = 총 예상 수당 - 지급예정 - 지급완료 + // 입금대기 금액 = 총 예상 수당 - 지급예정 - 지급완료 $pendingAmount = max(0, $totalExpectedCommission - $scheduledAmount - $paidCommission); // 1차/2차 분할 (각 50%) diff --git a/app/Http/Controllers/TenantController.php b/app/Http/Controllers/TenantController.php index b437a240..3c23bfdd 100644 --- a/app/Http/Controllers/TenantController.php +++ b/app/Http/Controllers/TenantController.php @@ -56,7 +56,11 @@ public function switch(Request $request) $tenantId = $request->input('tenant_id'); if ($tenantId === 'all') { - $request->session()->forget('selected_tenant_id'); + // "전체 보기" 대신 사용자의 HQ 테넌트로 설정 + $hqTenant = auth()->user()->getHQTenant(); + if ($hqTenant) { + $request->session()->put('selected_tenant_id', $hqTenant->id); + } } else { $request->session()->put('selected_tenant_id', $tenantId); } diff --git a/app/Models/Barobill/HometaxInvoice.php b/app/Models/Barobill/HometaxInvoice.php new file mode 100644 index 00000000..73dffaa2 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoice.php @@ -0,0 +1,266 @@ + 'integer', + 'write_date' => 'date', + 'issue_date' => 'date', + 'send_date' => 'date', + 'supply_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_type' => 'integer', + 'purpose_type' => 'integer', + 'issue_type' => 'integer', + 'is_checked' => 'boolean', + 'synced_at' => 'datetime', + ]; + + // 과세유형 상수 + public const TAX_TYPE_TAXABLE = 1; // 과세 + public const TAX_TYPE_ZERO_RATE = 2; // 영세 + public const TAX_TYPE_EXEMPT = 3; // 면세 + + // 영수/청구 상수 + public const PURPOSE_TYPE_RECEIPT = 1; // 영수 + public const PURPOSE_TYPE_CLAIM = 2; // 청구 + + // 발급유형 상수 + public const ISSUE_TYPE_NORMAL = 1; // 정발행 + public const ISSUE_TYPE_REVERSE = 2; // 역발행 + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 매출 스코프 + */ + public function scopeSales($query) + { + return $query->where('invoice_type', 'sales'); + } + + /** + * 매입 스코프 + */ + public function scopePurchase($query) + { + return $query->where('invoice_type', 'purchase'); + } + + /** + * 기간 스코프 + */ + public function scopePeriod($query, string $startDate, string $endDate, string $dateType = 'write') + { + $column = match($dateType) { + 'issue' => 'issue_date', + 'send' => 'send_date', + default => 'write_date', + }; + + return $query->whereBetween($column, [$startDate, $endDate]); + } + + /** + * 거래처 검색 스코프 + */ + public function scopeSearchCorp($query, string $keyword, string $invoiceType = 'sales') + { + if (empty($keyword)) { + return $query; + } + + // 매출이면 공급받는자, 매입이면 공급자 검색 + if ($invoiceType === 'sales') { + return $query->where(function ($q) use ($keyword) { + $q->where('invoicee_corp_name', 'like', "%{$keyword}%") + ->orWhere('invoicee_corp_num', 'like', "%{$keyword}%"); + }); + } else { + return $query->where(function ($q) use ($keyword) { + $q->where('invoicer_corp_name', 'like', "%{$keyword}%") + ->orWhere('invoicer_corp_num', 'like', "%{$keyword}%"); + }); + } + } + + /** + * 과세유형 라벨 + */ + public function getTaxTypeNameAttribute(): string + { + return match($this->tax_type) { + self::TAX_TYPE_TAXABLE => '과세', + self::TAX_TYPE_ZERO_RATE => '영세', + self::TAX_TYPE_EXEMPT => '면세', + default => '-', + }; + } + + /** + * 영수/청구 라벨 + */ + public function getPurposeTypeNameAttribute(): string + { + return match($this->purpose_type) { + self::PURPOSE_TYPE_RECEIPT => '영수', + self::PURPOSE_TYPE_CLAIM => '청구', + default => '-', + }; + } + + /** + * 발급유형 라벨 + */ + public function getIssueTypeNameAttribute(): string + { + return match($this->issue_type) { + self::ISSUE_TYPE_NORMAL => '정발급', + self::ISSUE_TYPE_REVERSE => '역발급', + default => '-', + }; + } + + /** + * 포맷된 공급가액 + */ + public function getFormattedSupplyAmountAttribute(): string + { + return number_format($this->supply_amount); + } + + /** + * 포맷된 세액 + */ + public function getFormattedTaxAmountAttribute(): string + { + return number_format($this->tax_amount); + } + + /** + * 포맷된 합계 + */ + public function getFormattedTotalAmountAttribute(): string + { + return number_format($this->total_amount); + } + + /** + * API 응답 데이터를 모델 데이터로 변환 + */ + public static function fromApiData(array $apiData, int $tenantId, string $invoiceType): array + { + // 작성일자 파싱 + $writeDate = null; + if (!empty($apiData['writeDate']) && strlen($apiData['writeDate']) >= 8) { + $writeDate = substr($apiData['writeDate'], 0, 4) . '-' . + substr($apiData['writeDate'], 4, 2) . '-' . + substr($apiData['writeDate'], 6, 2); + } + + // 발급일자 파싱 + $issueDate = null; + if (!empty($apiData['issueDT']) && strlen($apiData['issueDT']) >= 8) { + $issueDate = substr($apiData['issueDT'], 0, 4) . '-' . + substr($apiData['issueDT'], 4, 2) . '-' . + substr($apiData['issueDT'], 6, 2); + } + + return [ + 'tenant_id' => $tenantId, + 'nts_confirm_num' => $apiData['ntsConfirmNum'] ?? '', + 'invoice_type' => $invoiceType, + 'write_date' => $writeDate, + 'issue_date' => $issueDate, + 'invoicer_corp_num' => $apiData['invoicerCorpNum'] ?? '', + 'invoicer_corp_name' => $apiData['invoicerCorpName'] ?? '', + 'invoicer_ceo_name' => $apiData['invoicerCEOName'] ?? null, + 'invoicee_corp_num' => $apiData['invoiceeCorpNum'] ?? '', + 'invoicee_corp_name' => $apiData['invoiceeCorpName'] ?? '', + 'invoicee_ceo_name' => $apiData['invoiceeCEOName'] ?? null, + 'supply_amount' => (int)($apiData['supplyAmount'] ?? 0), + 'tax_amount' => (int)($apiData['taxAmount'] ?? 0), + 'total_amount' => (int)($apiData['totalAmount'] ?? 0), + 'tax_type' => (int)($apiData['taxType'] ?? 1), + 'purpose_type' => (int)($apiData['purposeType'] ?? 1), + 'issue_type' => 1, // 기본값: 정발행 + 'item_name' => $apiData['itemName'] ?? null, + 'remark' => $apiData['remark'] ?? null, + 'synced_at' => now(), + ]; + } +} diff --git a/app/Models/VehicleLog.php b/app/Models/VehicleLog.php index ded7a901..44d13968 100644 --- a/app/Models/VehicleLog.php +++ b/app/Models/VehicleLog.php @@ -2,13 +2,14 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class VehicleLog extends Model { - use SoftDeletes; + use HasFactory, SoftDeletes; protected $fillable = [ 'tenant_id', @@ -28,65 +29,63 @@ class VehicleLog extends Model ]; protected $casts = [ - 'log_date' => 'date:Y-m-d', + 'log_date' => 'date', 'distance_km' => 'integer', ]; - // trip_type 상수 - public const TRIP_TYPE_COMMUTE_TO = 'commute_to'; - public const TRIP_TYPE_COMMUTE_FROM = 'commute_from'; - public const TRIP_TYPE_BUSINESS = 'business'; - public const TRIP_TYPE_PERSONAL = 'personal'; - public const TRIP_TYPE_COMMUTE_ROUND = 'commute_round'; - public const TRIP_TYPE_BUSINESS_ROUND = 'business_round'; - public const TRIP_TYPE_PERSONAL_ROUND = 'personal_round'; - - // location_type 상수 - public const LOCATION_TYPE_HOME = 'home'; - public const LOCATION_TYPE_OFFICE = 'office'; - public const LOCATION_TYPE_CLIENT = 'client'; - public const LOCATION_TYPE_OTHER = 'other'; - - public static function tripTypeLabels(): array - { - return [ - self::TRIP_TYPE_COMMUTE_TO => '출근용', - self::TRIP_TYPE_COMMUTE_FROM => '퇴근용', - self::TRIP_TYPE_BUSINESS => '업무용', - self::TRIP_TYPE_PERSONAL => '비업무용(개인)', - self::TRIP_TYPE_COMMUTE_ROUND => '출퇴근용(왕복)', - self::TRIP_TYPE_BUSINESS_ROUND => '업무용(왕복)', - self::TRIP_TYPE_PERSONAL_ROUND => '비업무용(왕복)', - ]; - } - - public static function locationTypeLabels(): array - { - return [ - self::LOCATION_TYPE_HOME => '자택', - self::LOCATION_TYPE_OFFICE => '회사', - self::LOCATION_TYPE_CLIENT => '거래처', - self::LOCATION_TYPE_OTHER => '기타', - ]; - } - + /** + * 차량 관계 + */ public function vehicle(): BelongsTo { return $this->belongsTo(CorporateVehicle::class, 'vehicle_id'); } - public function getTripTypeLabelAttribute(): string + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo { - return self::tripTypeLabels()[$this->trip_type] ?? $this->trip_type; + return $this->belongsTo(Tenant::class); } - public function getDepartureTypeLabelAttribute(): string + /** + * 구분(trip_type) 목록 + */ + public static function getTripTypes(): array { - return self::locationTypeLabels()[$this->departure_type] ?? ($this->departure_type ?? ''); + return [ + 'commute_to' => '출근용', + 'commute_from' => '퇴근용', + 'business' => '업무용', + 'personal' => '비업무', + ]; } - public function getArrivalTypeLabelAttribute(): string + /** + * 분류(location_type) 목록 + */ + public static function getLocationTypes(): array { - return self::locationTypeLabels()[$this->arrival_type] ?? ($this->arrival_type ?? ''); + return [ + 'home' => '자택', + 'office' => '회사', + 'client' => '거래처', + 'other' => '기타', + ]; + } + + /** + * 비고 목록 + */ + public static function getNoteOptions(): array + { + return [ + '거래처방문', + '제조시설등', + '회의참석', + '판촉활동', + '교육등', + ]; } } diff --git a/app/Models/VehicleMaintenance.php b/app/Models/VehicleMaintenance.php new file mode 100644 index 00000000..48dc64bd --- /dev/null +++ b/app/Models/VehicleMaintenance.php @@ -0,0 +1,55 @@ + 'date', + 'amount' => 'integer', + 'mileage' => 'integer', + ]; + + /** + * 차량 관계 + */ + public function vehicle(): BelongsTo + { + return $this->belongsTo(CorporateVehicle::class, 'vehicle_id'); + } + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 카테고리 목록 + */ + public static function getCategories(): array + { + return ['주유', '정비', '보험', '세차', '주차', '통행료', '검사', '기타']; + } +} diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index 6ab63f0f..2cdd2767 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -25,6 +25,14 @@ public function boot(): void // 모든 뷰에 테넌트 목록 공유 (전역용) View::composer('*', function ($view) { if (auth()->check()) { + // 테넌트가 선택되지 않은 경우 자동으로 HQ 테넌트 설정 + if (!session('selected_tenant_id')) { + $hqTenant = auth()->user()->getHQTenant(); + if ($hqTenant) { + session(['selected_tenant_id' => $hqTenant->id]); + } + } + $globalTenants = Tenant::active() ->orderBy('company_name') ->get(['id', 'company_name', 'code']); diff --git a/app/Services/Barobill/BarobillUsageService.php b/app/Services/Barobill/BarobillUsageService.php index 9cc38e28..8bcc2534 100644 --- a/app/Services/Barobill/BarobillUsageService.php +++ b/app/Services/Barobill/BarobillUsageService.php @@ -4,6 +4,7 @@ use App\Models\Barobill\BarobillMember; use App\Models\Barobill\BarobillPricingPolicy; +use App\Models\Barobill\HometaxInvoice; use Illuminate\Support\Facades\Log; use Illuminate\Support\Collection; @@ -33,14 +34,20 @@ public function __construct(BarobillService $barobillService) * @param string $startDate 시작일 (YYYYMMDD) * @param string $endDate 종료일 (YYYYMMDD) * @param int|null $tenantId 특정 테넌트만 조회 (null이면 전체) + * @param bool $productionOnly 운영 모드만 조회 (기본값: true) * @return array */ - public function getUsageList(string $startDate, string $endDate, ?int $tenantId = null): array + public function getUsageList(string $startDate, string $endDate, ?int $tenantId = null, bool $productionOnly = true): array { $query = BarobillMember::query() ->where('status', 'active') ->with('tenant:id,company_name'); + // 운영 모드만 조회 (테스트 모드 제외) + if ($productionOnly) { + $query->where('server_mode', 'production'); + } + if ($tenantId) { $query->where('tenant_id', $tenantId); } @@ -66,6 +73,9 @@ public function getUsageList(string $startDate, string $endDate, ?int $tenantId */ public function getMemberUsage(BarobillMember $member, string $startDate, string $endDate): array { + // 해당 회원사의 서버 모드로 전환 (테스트/운영) + $this->barobillService->setServerMode($member->server_mode ?? 'test'); + $taxInvoiceCount = $this->getTaxInvoiceCount($member, $startDate, $endDate); $bankAccountCount = $this->getBankAccountCount($member, $startDate, $endDate); $cardCount = $this->getCardCount($member, $startDate, $endDate); @@ -133,16 +143,29 @@ public function aggregateStats(array $usageList): array /** * 전자세금계산서 발행 건수 조회 * - * 바로빌 API에 직접적인 세금계산서 건수 조회 API가 없어서 - * 임시로 0을 반환합니다. 실제 구현 시 발행 내역 조회 API 활용 필요. + * HometaxInvoice 테이블에서 해당 테넌트의 매출 세금계산서 건수를 카운트합니다. + * (매출 = 발행한 세금계산서) */ protected function getTaxInvoiceCount(BarobillMember $member, string $startDate, string $endDate): int { - // TODO: 세금계산서 발행 내역 조회 API 연동 - // 현재 바로빌 API에서 제공하는 세금계산서 목록 조회 기능 활용 필요 - // GetTaxInvoiceList 등의 API 활용 + try { + // YYYYMMDD -> YYYY-MM-DD 형식 변환 + $start = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2); + $end = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2); - return 0; + return HometaxInvoice::where('tenant_id', $member->tenant_id) + ->where('invoice_type', 'sales') // 매출 (발행한 세금계산서) + ->whereBetween('write_date', [$start, $end]) + ->count(); + } catch (\Exception $e) { + Log::warning('세금계산서 건수 조회 실패', [ + 'member_id' => $member->id, + 'tenant_id' => $member->tenant_id, + 'error' => $e->getMessage(), + ]); + + return 0; + } } /** @@ -155,6 +178,15 @@ protected function getBankAccountCount(BarobillMember $member, string $startDate try { $result = $this->barobillService->getBankAccounts($member->biz_no, true); + Log::info('계좌 조회 API 응답', [ + 'member_id' => $member->id, + 'biz_no' => $member->biz_no, + 'server_mode' => $member->server_mode, + 'success' => $result['success'] ?? false, + 'data_type' => isset($result['data']) ? gettype($result['data']) : 'null', + 'data' => $result['data'] ?? null, + ]); + if ($result['success'] && isset($result['data'])) { // 배열이면 count if (is_array($result['data'])) { @@ -162,9 +194,13 @@ protected function getBankAccountCount(BarobillMember $member, string $startDate } // 객체에 계좌 목록이 있으면 if (is_object($result['data'])) { - // BankAccountInfoEx 배열이 있는 경우 - if (isset($result['data']->BankAccountInfoEx)) { - $accounts = $result['data']->BankAccountInfoEx; + // GetBankAccountEx 응답: BankAccount 또는 BankAccountEx 배열 + if (isset($result['data']->BankAccount)) { + $accounts = $result['data']->BankAccount; + return is_array($accounts) ? count($accounts) : 1; + } + if (isset($result['data']->BankAccountEx)) { + $accounts = $result['data']->BankAccountEx; return is_array($accounts) ? count($accounts) : 1; } } @@ -189,6 +225,15 @@ protected function getCardCount(BarobillMember $member, string $startDate, strin try { $result = $this->barobillService->getCards($member->biz_no, true); + Log::info('카드 조회 API 응답', [ + 'member_id' => $member->id, + 'biz_no' => $member->biz_no, + 'server_mode' => $member->server_mode, + 'success' => $result['success'] ?? false, + 'data_type' => isset($result['data']) ? gettype($result['data']) : 'null', + 'data' => $result['data'] ?? null, + ]); + if ($result['success'] && isset($result['data'])) { // 배열이면 count if (is_array($result['data'])) { @@ -196,9 +241,9 @@ protected function getCardCount(BarobillMember $member, string $startDate, strin } // 객체에 카드 목록이 있으면 if (is_object($result['data'])) { - // CardInfoEx2 배열이 있는 경우 - if (isset($result['data']->CardInfoEx2)) { - $cards = $result['data']->CardInfoEx2; + // GetCardEx2 응답: CardEx 배열 + if (isset($result['data']->CardEx)) { + $cards = $result['data']->CardEx; return is_array($cards) ? count($cards) : 1; } } @@ -216,15 +261,28 @@ protected function getCardCount(BarobillMember $member, string $startDate, strin /** * 홈텍스 매입/매출 건수 조회 * - * 바로빌 API에서 홈텍스 매입/매출 조회 API 활용 필요. - * 현재는 임시로 0 반환. + * HometaxInvoice 테이블에서 해당 테넌트의 전체 세금계산서 건수를 카운트합니다. + * (매입 + 매출 모두 포함) */ protected function getHometaxCount(BarobillMember $member, string $startDate, string $endDate): int { - // TODO: 홈텍스 매입/매출 조회 API 연동 - // GetHomeTaxTaxInvoice 등의 API 활용 필요 + try { + // YYYYMMDD -> YYYY-MM-DD 형식 변환 + $start = substr($startDate, 0, 4) . '-' . substr($startDate, 4, 2) . '-' . substr($startDate, 6, 2); + $end = substr($endDate, 0, 4) . '-' . substr($endDate, 4, 2) . '-' . substr($endDate, 6, 2); - return 0; + return HometaxInvoice::where('tenant_id', $member->tenant_id) + ->whereBetween('write_date', [$start, $end]) + ->count(); + } catch (\Exception $e) { + Log::warning('홈텍스 건수 조회 실패', [ + 'member_id' => $member->id, + 'tenant_id' => $member->tenant_id, + 'error' => $e->getMessage(), + ]); + + return 0; + } } /** diff --git a/app/Services/Barobill/HometaxSyncService.php b/app/Services/Barobill/HometaxSyncService.php new file mode 100644 index 00000000..890ade03 --- /dev/null +++ b/app/Services/Barobill/HometaxSyncService.php @@ -0,0 +1,229 @@ + int, 'updated' => int, 'failed' => int] + */ + public function syncInvoices(array $invoices, int $tenantId, string $invoiceType): array + { + $result = [ + 'inserted' => 0, + 'updated' => 0, + 'failed' => 0, + 'total' => count($invoices), + ]; + + if (empty($invoices)) { + return $result; + } + + DB::beginTransaction(); + + try { + foreach ($invoices as $apiData) { + // 국세청승인번호가 없으면 스킵 + if (empty($apiData['ntsConfirmNum'])) { + $result['failed']++; + continue; + } + + $modelData = HometaxInvoice::fromApiData($apiData, $tenantId, $invoiceType); + + // upsert (있으면 업데이트, 없으면 삽입) + $existing = HometaxInvoice::where('tenant_id', $tenantId) + ->where('nts_confirm_num', $modelData['nts_confirm_num']) + ->where('invoice_type', $invoiceType) + ->first(); + + if ($existing) { + // 기존 데이터 업데이트 (자체 관리 필드는 유지) + $existing->update([ + 'write_date' => $modelData['write_date'], + 'issue_date' => $modelData['issue_date'], + 'invoicer_corp_num' => $modelData['invoicer_corp_num'], + 'invoicer_corp_name' => $modelData['invoicer_corp_name'], + 'invoicer_ceo_name' => $modelData['invoicer_ceo_name'], + 'invoicee_corp_num' => $modelData['invoicee_corp_num'], + 'invoicee_corp_name' => $modelData['invoicee_corp_name'], + 'invoicee_ceo_name' => $modelData['invoicee_ceo_name'], + 'supply_amount' => $modelData['supply_amount'], + 'tax_amount' => $modelData['tax_amount'], + 'total_amount' => $modelData['total_amount'], + 'tax_type' => $modelData['tax_type'], + 'purpose_type' => $modelData['purpose_type'], + 'item_name' => $modelData['item_name'], + 'remark' => $modelData['remark'], + 'synced_at' => now(), + ]); + $result['updated']++; + } else { + // 새 데이터 삽입 + HometaxInvoice::create($modelData); + $result['inserted']++; + } + } + + DB::commit(); + + Log::info('[HometaxSync] 동기화 완료', [ + 'tenant_id' => $tenantId, + 'invoice_type' => $invoiceType, + 'result' => $result, + ]); + + } catch (\Throwable $e) { + DB::rollBack(); + Log::error('[HometaxSync] 동기화 실패', [ + 'tenant_id' => $tenantId, + 'invoice_type' => $invoiceType, + 'error' => $e->getMessage(), + ]); + throw $e; + } + + return $result; + } + + /** + * 로컬 DB에서 세금계산서 목록 조회 + * + * @param int $tenantId 테넌트 ID + * @param string $invoiceType 'sales' 또는 'purchase' + * @param string $startDate 시작일 (Y-m-d) + * @param string $endDate 종료일 (Y-m-d) + * @param string $dateType 날짜 타입 ('write', 'issue', 'send') + * @param string|null $searchCorp 거래처 검색어 + * @return array + */ + public function getLocalInvoices( + int $tenantId, + string $invoiceType, + string $startDate, + string $endDate, + string $dateType = 'write', + ?string $searchCorp = null + ): array { + $query = HometaxInvoice::where('tenant_id', $tenantId) + ->where('invoice_type', $invoiceType) + ->period($startDate, $endDate, $dateType); + + if (!empty($searchCorp)) { + $query->searchCorp($searchCorp, $invoiceType); + } + + $invoices = $query->orderByDesc('write_date')->get(); + + // API 응답 형식에 맞게 변환 + $formattedInvoices = $invoices->map(function ($inv) { + return [ + 'id' => $inv->id, + 'ntsConfirmNum' => $inv->nts_confirm_num, + 'writeDate' => $inv->write_date?->format('Ymd'), + 'writeDateFormatted' => $inv->write_date?->format('Y-m-d'), + 'issueDT' => $inv->issue_date?->format('Ymd'), + 'issueDateFormatted' => $inv->issue_date?->format('Y-m-d'), + 'invoicerCorpNum' => $inv->invoicer_corp_num, + 'invoicerCorpName' => $inv->invoicer_corp_name, + 'invoicerCEOName' => $inv->invoicer_ceo_name, + 'invoiceeCorpNum' => $inv->invoicee_corp_num, + 'invoiceeCorpName' => $inv->invoicee_corp_name, + 'invoiceeCEOName' => $inv->invoicee_ceo_name, + 'supplyAmount' => $inv->supply_amount, + 'supplyAmountFormatted' => $inv->formatted_supply_amount, + 'taxAmount' => $inv->tax_amount, + 'taxAmountFormatted' => $inv->formatted_tax_amount, + 'totalAmount' => $inv->total_amount, + 'totalAmountFormatted' => $inv->formatted_total_amount, + 'taxType' => $inv->tax_type, + 'taxTypeName' => $inv->tax_type_name, + 'purposeType' => $inv->purpose_type, + 'purposeTypeName' => $inv->purpose_type_name, + 'issueTypeName' => $inv->issue_type_name, + 'itemName' => $inv->item_name, + 'remark' => $inv->remark, + 'memo' => $inv->memo, + 'category' => $inv->category, + 'isChecked' => $inv->is_checked, + 'syncedAt' => $inv->synced_at?->format('Y-m-d H:i:s'), + ]; + })->toArray(); + + // 요약 계산 + $summary = [ + 'totalAmount' => $invoices->sum('supply_amount'), + 'totalTax' => $invoices->sum('tax_amount'), + 'totalSum' => $invoices->sum('total_amount'), + 'count' => $invoices->count(), + ]; + + return [ + 'invoices' => $formattedInvoices, + 'summary' => $summary, + ]; + } + + /** + * 마지막 동기화 시간 조회 + */ + public function getLastSyncTime(int $tenantId, string $invoiceType): ?string + { + $lastSync = HometaxInvoice::where('tenant_id', $tenantId) + ->where('invoice_type', $invoiceType) + ->orderByDesc('synced_at') + ->value('synced_at'); + + return $lastSync?->format('Y-m-d H:i:s'); + } + + /** + * 메모 업데이트 + */ + public function updateMemo(int $id, int $tenantId, ?string $memo): bool + { + return HometaxInvoice::where('id', $id) + ->where('tenant_id', $tenantId) + ->update(['memo' => $memo]) > 0; + } + + /** + * 확인 여부 토글 + */ + public function toggleChecked(int $id, int $tenantId): bool + { + $invoice = HometaxInvoice::where('id', $id) + ->where('tenant_id', $tenantId) + ->first(); + + if (!$invoice) { + return false; + } + + $invoice->is_checked = !$invoice->is_checked; + return $invoice->save(); + } + + /** + * 분류 태그 업데이트 + */ + public function updateCategory(int $id, int $tenantId, ?string $category): bool + { + return HometaxInvoice::where('id', $id) + ->where('tenant_id', $tenantId) + ->update(['category' => $category]) > 0; + } +} diff --git a/resources/markdown/영업파트너가이드북.md b/resources/markdown/영업파트너가이드북.md index 64eab522..a68cfc57 100644 --- a/resources/markdown/영업파트너가이드북.md +++ b/resources/markdown/영업파트너가이드북.md @@ -280,17 +280,17 @@ ### 수당 상태 구분 | 상태 | 색상 | 설명 | |------|------|------| -| 납입대기 | 회색 | 고객의 개발비 입금 대기 중 | +| 입금대기 | 회색 | 고객의 개발비 입금 대기 중 | | 지급예정 | 노란색 | 개발비 입금 완료, 익월 10일 지급 예정 | | 지급완료 | 초록색 | 수당 지급 완료 | ### 수당 지급 프로세스 ``` -납입대기 → 개발비 입금 → 지급예정 → 익월 10일 → 지급완료 +입금대기 → 개발비 입금 → 지급예정 → 익월 10일 → 지급완료 ``` -1. **납입대기**: 고객의 개발비 입금을 기다리는 상태 +1. **입금대기**: 고객의 개발비 입금을 기다리는 상태 2. **지급예정**: 개발비가 입금되면 자동으로 지급예정 상태로 변경 3. **지급완료**: 매월 10일에 지급예정 건을 일괄 지급 @@ -300,8 +300,8 @@ ### 수당 현황 확인 대시보드 → **내 수당 합계** 카드 클릭 -- 1차 수당: 납입대기 / 지급예정 / 지급완료 금액 -- 2차 수당: 납입대기 / 지급예정 / 지급완료 금액 +- 1차 수당: 입금대기 / 지급예정 / 지급완료 금액 +- 2차 수당: 입금대기 / 지급예정 / 지급완료 금액 - 진행률 바로 지급 현황 시각화 --- diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 1f123450..3b12f2e5 100644 --- a/resources/views/barobill/eaccount/index.blade.php +++ b/resources/views/barobill/eaccount/index.blade.php @@ -605,6 +605,7 @@ className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font- onDateToChange, onThisMonth, onLastMonth, + onSearch, totalCount, accountCodes, onAccountCodeChange, @@ -661,6 +662,15 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s > 지난달 + 조회: {logs.length}건 @@ -817,15 +827,9 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2 useEffect(() => { loadAccounts(); loadAccountCodes(); + loadTransactions(); }, []); - // 날짜 또는 계좌 변경 시 거래내역 로드 - useEffect(() => { - if (dateFrom && dateTo) { - loadTransactions(); - } - }, [dateFrom, dateTo, selectedAccount]); - const loadAccounts = async () => { try { const response = await fetch(API.accounts); @@ -1056,6 +1060,7 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2 onDateToChange={setDateTo} onThisMonth={handleThisMonth} onLastMonth={handleLastMonth} + onSearch={() => loadTransactions()} totalCount={summary.count || logs.length} accountCodes={accountCodes} onAccountCodeChange={handleAccountCodeChange} diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index e3e048fe..2c2a92a6 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -728,7 +728,7 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l 카드정보 공제 증빙/판매자상호 - 내역 + 내역 금액 부가세 승인번호 @@ -940,15 +940,9 @@ className="text-xs text-amber-600 hover:text-amber-700 underline" useEffect(() => { loadCards(); loadAccountCodes(); + loadTransactions(); }, []); - // 날짜 또는 카드 변경 시 거래내역 로드 - useEffect(() => { - if (dateFrom && dateTo) { - loadTransactions(); - } - }, [dateFrom, dateTo, selectedCard]); - const loadCards = async () => { try { const response = await fetch(API.cards); @@ -1272,28 +1266,35 @@ className="text-xs text-amber-600 hover:text-amber-700 underline" {/* Dashboard */} -
+
} color="purple" /> } color="green" /> } + title="불공제" + value={formatCurrency(summary.nonDeductibleAmount)} + subtext={`${(summary.nonDeductibleCount || 0).toLocaleString()}건`} + icon={} color="red" /> + } + color="stone" + /> biz_no ?? "" }}', name: '{{ $barobillMember?->corp_name ?? $currentTenant?->company_name ?? "" }}', ceo: '{{ $barobillMember?->ceo_name ?? $currentTenant?->ceo_name ?? "" }}', addr: '{{ $barobillMember?->addr ?? $currentTenant?->address ?? "" }}', + bizType: '{{ $barobillMember?->biz_type ?? "" }}', + bizClass: '{{ $barobillMember?->biz_class ?? "" }}', contact: '{{ $barobillMember?->manager_name ?? "" }}', + contactPhone: '{{ $barobillMember?->manager_hp ?? "" }}', email: '{{ $barobillMember?->manager_email ?? $currentTenant?->email ?? "" }}' }; @@ -112,50 +117,73 @@ return `${year}-${month}-${day}`; }; - // StatCard Component - const StatCard = ({ title, value, subtext, icon }) => ( -
-
-

{title}

-
- {icon} -
-
-
{value}
- {subtext &&
{subtext}
} -
- ); - // IssueForm Component - const IssueForm = ({ onIssue, onCancel }) => { + const IssueForm = ({ onIssue, onCancel, supplier }) => { const generateRandomData = () => { - const randomRecipient = RECIPIENT_COMPANIES[Math.floor(Math.random() * RECIPIENT_COMPANIES.length)]; - const itemNames = ['시멘트 50kg', '철근 10mm', '타일 30x30', '도배지', '접착제', '페인트 18L', '유리 5mm', '목재 합판', '단열재', '방수재']; - const itemCount = Math.floor(Math.random() * 3) + 1; - const items = []; - for (let i = 0; i < itemCount; i++) { - const itemName = itemNames[Math.floor(Math.random() * itemNames.length)]; - const qty = Math.floor(Math.random() * 100) + 1; - const unitPrice = Math.floor(Math.random() * 499000) + 1000; - items.push({ name: itemName, qty, unitPrice, vatType: 'vat' }); + let items = []; + let supplyDate; + + if (IS_TEST_MODE) { + // 테스트 모드: 랜덤 품목 데이터 생성 + const itemNames = ['시멘트 50kg', '철근 10mm', '타일 30x30', '도배지', '접착제', '페인트 18L', '유리 5mm', '목재 합판', '단열재', '방수재']; + const itemCount = Math.floor(Math.random() * 3) + 1; + const randomDaysAgo = Math.floor(Math.random() * 30); + supplyDate = new Date(); + supplyDate.setDate(supplyDate.getDate() - randomDaysAgo); + const testMonth = String(supplyDate.getMonth() + 1).padStart(2, '0'); + for (let i = 0; i < itemCount; i++) { + const itemName = itemNames[Math.floor(Math.random() * itemNames.length)]; + const qty = Math.floor(Math.random() * 100) + 1; + const unitPrice = Math.floor(Math.random() * 499000) + 1000; + const randomDay = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0'); + items.push({ name: itemName, qty, unitPrice, vatType: 'vat', month: testMonth, day: randomDay }); + } + } else { + // 운영 모드: 빈 품목 1건 + supplyDate = new Date(); + const defaultMonth = String(supplyDate.getMonth() + 1).padStart(2, '0'); + items = [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat', month: defaultMonth, day: '' }]; + } + + // 공급받는자: 테스트 모드에서만 샘플 데이터, 운영 모드에서는 비움 + let recipientData = { + recipientBizno: '', + recipientName: '', + recipientCeo: '', + recipientAddr: '', + recipientBizType: '', + recipientBizClass: '', + recipientContact: '', + recipientContactPhone: '', + recipientEmail: '', + }; + + if (IS_TEST_MODE) { + const randomRecipient = RECIPIENT_COMPANIES[Math.floor(Math.random() * RECIPIENT_COMPANIES.length)]; + recipientData = { + recipientBizno: randomRecipient.bizno, + recipientName: randomRecipient.name, + recipientCeo: randomRecipient.ceo, + recipientAddr: randomRecipient.addr, + recipientBizType: randomRecipient.bizType || '', + recipientBizClass: randomRecipient.bizClass || '', + recipientContact: randomRecipient.contact || '홍길동', + recipientContactPhone: randomRecipient.contactPhone || '', + recipientEmail: randomRecipient.email, + }; } - const randomDaysAgo = Math.floor(Math.random() * 30); - const supplyDate = new Date(); - supplyDate.setDate(supplyDate.getDate() - randomDaysAgo); return { - supplierBizno: FIXED_SUPPLIER.bizno, - supplierName: FIXED_SUPPLIER.name, - supplierCeo: FIXED_SUPPLIER.ceo, - supplierAddr: FIXED_SUPPLIER.addr, - supplierContact: FIXED_SUPPLIER.contact, - supplierEmail: FIXED_SUPPLIER.email, - recipientBizno: randomRecipient.bizno, - recipientName: randomRecipient.name, - recipientCeo: randomRecipient.ceo, - recipientAddr: randomRecipient.addr, - recipientContact: randomRecipient.contact || '홍길동', - recipientEmail: randomRecipient.email, + supplierBizno: supplier.bizno, + supplierName: supplier.name, + supplierCeo: supplier.ceo, + supplierAddr: supplier.addr, + supplierBizType: supplier.bizType, + supplierBizClass: supplier.bizClass, + supplierContact: supplier.contact, + supplierContactPhone: supplier.contactPhone, + supplierEmail: supplier.email, + ...recipientData, supplyDate: formatLocalDate(supplyDate), items, memo: '' @@ -166,7 +194,8 @@ const [isSubmitting, setIsSubmitting] = useState(false); const handleAddItem = () => { - setFormData({ ...formData, items: [...formData.items, { name: '', qty: 1, unitPrice: 0, vatType: 'vat' }] }); + const currentMonth = formData.supplyDate ? formData.supplyDate.substring(5, 7) : String(new Date().getMonth() + 1).padStart(2, '0'); + setFormData({ ...formData, items: [...formData.items, { name: '', qty: 1, unitPrice: 0, vatType: 'vat', month: currentMonth, day: '' }] }); }; const handleItemChange = (index, field, value) => { @@ -201,64 +230,186 @@ setFormData(generateRandomData()); }; + // 테이블 셀 스타일 + const thStyleRed = "px-3 py-2.5 bg-red-50 text-red-600 font-medium text-center text-xs border border-gray-200 whitespace-nowrap"; + const thStyleBlue = "px-3 py-2.5 bg-blue-50 text-blue-600 font-medium text-center text-xs border border-gray-200 whitespace-nowrap"; + const tdStyle = "px-2 py-1.5 border border-gray-200"; + const inputReadonly = "w-full px-2 py-1.5 text-sm bg-gray-50 border-0 outline-none text-gray-700"; + const inputEditable = "w-full px-2 py-1.5 text-sm bg-white border-0 outline-none focus:ring-2 focus:ring-blue-400 rounded"; + return (
- {/* 공급자 정보 */} -
-

공급자 정보

-
-
- - + {/* 공급자 / 공급받는자 좌우 배치 */} +
+
+ {/* === 공급자 (왼쪽 - 분홍색) === */} +
+ + + + + + + + + + {/* 등록번호 / 종사업장 */} + + + + + + + + {/* 상호 / 성명 */} + + + + + + + {/* 사업장주소 */} + + + + + {/* 업태 / 종목 */} + + + + + + + {/* 담당자 / 연락처 */} + + + + + + + {/* 이메일 */} + + + + + +
+ 공급자 + 등록번호 + + 종사업장 + +
상호 + + 성명 + +
사업장
주소
+ +
업태 + + 종목 + +
담당자 + + 연락처 + +
이메일 + +
-
- - -
-
- - -
-
- - + + {/* === 공급받는자 (오른쪽 - 파란색) === */} +
+ + + + + + + + + + {/* 등록번호 / 종사업장 */} + + + + + + + + {/* 상호 / 성명 */} + + + + + + + {/* 사업장주소 */} + + + + + {/* 업태 / 종목 */} + + + + + + + {/* 담당자 / 연락처 */} + + + + + + + {/* 이메일 */} + + + + + +
+ 공급받는자 + 등록번호 +
+ setFormData({ ...formData, recipientBizno: e.target.value })} required /> + +
+
종사업장 + +
상호 + setFormData({ ...formData, recipientName: e.target.value })} required /> + 성명 + setFormData({ ...formData, recipientCeo: e.target.value })} /> +
사업장
주소
+ setFormData({ ...formData, recipientAddr: e.target.value })} required /> +
업태 + setFormData({ ...formData, recipientBizType: e.target.value })} /> + 종목 + setFormData({ ...formData, recipientBizClass: e.target.value })} /> +
담당자 + setFormData({ ...formData, recipientContact: e.target.value })} /> + 연락처 + setFormData({ ...formData, recipientContactPhone: e.target.value })} /> +
+
+ + + + 이메일 +
+
+ setFormData({ ...formData, recipientEmail: e.target.value })} required /> +
- {/* 공급받는자 정보 */} -
-

공급받는자 정보

-
-
- - setFormData({ ...formData, recipientBizno: e.target.value })} required /> -
-
- - setFormData({ ...formData, recipientName: e.target.value })} required /> -
-
- - setFormData({ ...formData, recipientCeo: e.target.value })} /> -
-
- - setFormData({ ...formData, recipientAddr: e.target.value })} required /> -
-
- - setFormData({ ...formData, recipientContact: e.target.value })} /> -
-
- - setFormData({ ...formData, recipientEmail: e.target.value })} required /> -
-
- - setFormData({ ...formData, supplyDate: e.target.value })} required /> -
-
+ {/* 작성일자 */} +
+ + setFormData({ ...formData, supplyDate: e.target.value })} required />
@@ -272,7 +423,9 @@
- + + + @@ -283,6 +436,8 @@ + + @@ -306,6 +461,12 @@ return ( + + @@ -344,7 +505,7 @@ - + @@ -412,8 +573,8 @@ }; // InvoiceList Component - const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount, sortColumn, sortDirection, onSort }) => { - const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val); + const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, filters, updateFilter, onSearch, totalCount }) => { + const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val); const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR'); const getStatusBadge = (status) => { @@ -427,130 +588,159 @@ return {config.label}; }; - // 정렬 아이콘 - const SortIcon = ({ column }) => { - if (sortColumn !== column) { - return ( - - - - ); + // 기간 빠른 설정 + const setQuickDate = (offset) => { + const now = new Date(); + let from, to; + if (offset === '1w') { + from = new Date(now); from.setDate(from.getDate() - 7); + to = now; + } else if (offset === '1m') { + from = new Date(now.getFullYear(), now.getMonth(), 1); + to = new Date(now.getFullYear(), now.getMonth() + 1, 0); + } else if (offset === '3m') { + from = new Date(now.getFullYear(), now.getMonth() - 2, 1); + to = new Date(now.getFullYear(), now.getMonth() + 1, 0); } - return sortDirection === 'asc' ? ( - - - - ) : ( - - - - ); + updateFilter('dateFrom', formatLocalDate(from)); + updateFilter('dateTo', formatLocalDate(to)); }; - // 정렬 가능한 헤더 컴포넌트 - const SortableHeader = ({ column, children, className = '' }) => ( - - ); + // 스타일 + const labelCell = "px-3 py-2.5 bg-stone-50 text-stone-600 text-xs font-semibold whitespace-nowrap border border-stone-200 text-right"; + const valueCell = "px-2 py-1.5 border border-stone-200"; + const inputSm = "w-full px-2 py-1.5 text-sm border-0 outline-none bg-transparent focus:ring-0"; + const selectSm = "px-3 pr-8 py-1.5 text-sm border border-stone-200 rounded outline-none bg-white focus:ring-2 focus:ring-blue-400 appearance-none bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23666%22%20stroke-width%3D%222%22%3E%3Cpath%20d%3D%22M6%209l6%206%206-6%22%2F%3E%3C%2Fsvg%3E')] bg-no-repeat bg-[right_0.5rem_center]"; + const quickBtn = "px-2.5 py-1 text-xs font-medium border border-stone-300 rounded hover:bg-stone-100 transition-colors"; return ( -
-
-
-

발행 내역

- {/* 기간 조회 필터 */} -
-
- - onDateFromChange(e.target.value)} - className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" - /> - ~ - onDateToChange(e.target.value)} - className="rounded-lg border border-stone-200 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" - /> -
-
- - -
- - 조회: {invoices.length}건 - {totalCount !== invoices.length && ( - / 전체 {totalCount}건 - )} - -
-
-
-
-
품목명 수량 단가
+ { const v = e.target.value.replace(/[^0-9]/g, ''); handleItemChange(index, 'month', v); }} /> + + { const v = e.target.value.replace(/[^0-9]/g, ''); handleItemChange(index, 'day', v); }} /> + handleItemChange(index, 'name', e.target.value)} required />
합계합계 {formData.items.reduce((sum, item) => sum + (item.qty * item.unitPrice), 0).toLocaleString()} onSort(column)} - > -
- {children} - -
-
- +
+ {/* 검색 조건 패널 */} +
+
+ + {/* Row 1: 조회기간 */} - - 공급받는자 - 작성일자 - - - - - - + + + + + {/* Row 2: 사업자등록번호 / 상호 */} + + + + + {/* Row 3: 상태 / 정렬 / 조회건수 */} + + + - - - {invoices.length === 0 ? ( - - ) : ( - invoices.map((invoice) => ( - onViewDetail(invoice)}> - - - - - - - - - - - )) - )}
발행번호전송일자공급가액부가세합계상태작업 + *조회기간 + +
+ + updateFilter('dateFrom', e.target.value)} className={selectSm} /> + ~ + updateFilter('dateTo', e.target.value)} className={selectSm} /> + + + +
+
+ +
사업자번호 +
+ updateFilter('bizNo', e.target.value)} className={`${inputSm} max-w-[180px] border border-stone-200 rounded`} /> + | + 상호 + updateFilter('companyName', e.target.value)} className={`${inputSm} max-w-[180px] border border-stone-200 rounded`} /> +
+
조건 +
+ 상태 + + | + 정렬 + + + | + + 조회 {invoices.length}건 + {totalCount !== invoices.length && / 전체 {totalCount}건} + +
+
해당 기간에 발행된 세금계산서가 없습니다.
{invoice.issueKey || invoice.id}{invoice.recipientName}{formatDate(invoice.supplyDate)}{invoice.sentAt ? formatDate(invoice.sentAt) : -}{formatCurrency(invoice.totalSupplyAmt)}{formatCurrency(invoice.totalVat)}{formatCurrency(invoice.total)}{getStatusBadge(invoice.status)} e.stopPropagation()}> -
- {invoice.status === 'issued' && ( - - )} - -
-
+ + {/* 테이블 */} +
+
+ + + + + + + + + + + + + + + + {invoices.length === 0 ? ( + + ) : ( + invoices.map((invoice) => ( + onViewDetail(invoice)}> + + + + + + + + + + + )) + )} + +
발행번호공급받는자작성일자전송일자공급가액세액합계금액상태작업
해당 조건에 맞는 세금계산서가 없습니다.
{invoice.issueKey || invoice.id}{invoice.recipientName}{formatDate(invoice.supplyDate)}{invoice.sentAt ? formatDate(invoice.sentAt) : -}{formatCurrency(invoice.totalSupplyAmt)}{formatCurrency(invoice.totalVat)}{formatCurrency(invoice.total)}{getStatusBadge(invoice.status)} e.stopPropagation()}> +
+ {invoice.status === 'issued' && ( + + )} + +
+
+
+
); }; @@ -602,6 +792,7 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s + @@ -612,6 +803,7 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s {invoice.items?.map((item, index) => ( + @@ -622,12 +814,12 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s - + - + @@ -649,6 +841,127 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s ); }; + // SupplierSettingsModal Component + const SupplierSettingsModal = ({ supplier, onClose, onSaved }) => { + const [form, setForm] = useState({ + corp_name: supplier.name || '', + ceo_name: supplier.ceo || '', + addr: supplier.addr || '', + biz_type: supplier.bizType || '', + biz_class: supplier.bizClass || '', + manager_name: supplier.contact || '', + manager_hp: supplier.contactPhone || '', + manager_email: supplier.email || '', + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const handleChange = (field, value) => { + setForm(prev => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(''); + try { + const response = await fetch(API.supplierUpdate, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN }, + body: JSON.stringify(form), + }); + const result = await response.json(); + if (result.success) { + onSaved(result.supplier); + onClose(); + } else { + setError(result.error || '저장에 실패했습니다.'); + } + } catch (err) { + setError('저장 중 오류가 발생했습니다: ' + err.message); + } finally { + setSaving(false); + } + }; + + const labelClass = "block text-sm font-medium text-stone-700 mb-1"; + const inputClass = "w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"; + const readonlyClass = "w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-500"; + + return ( +
+
e.stopPropagation()}> +
+
+

공급자 기초정보 설정

+

세금계산서 발행 시 사용되는 공급자 정보입니다

+
+ +
+ + {error && ( +
{error}
+ )} +
+ + +

사업자번호는 변경할 수 없습니다

+
+
+
+ + handleChange('corp_name', e.target.value)} required /> +
+
+ + handleChange('ceo_name', e.target.value)} required /> +
+
+
+ + handleChange('addr', e.target.value)} /> +
+
+
+ + handleChange('biz_type', e.target.value)} placeholder="예: 정보통신업" /> +
+
+ + handleChange('biz_class', e.target.value)} placeholder="예: 소프트웨어 개발" /> +
+
+
+
+
+ + handleChange('manager_name', e.target.value)} /> +
+
+ + handleChange('manager_hp', e.target.value)} placeholder="예: 02-1234-5678" /> +
+
+
+ + handleChange('manager_email', e.target.value)} placeholder="예: tax@company.com" /> +
+
+ + +
+ +
+
+ ); + }; + // ApiLogs Component const ApiLogs = ({ logs, onClear }) => { if (logs.length === 0) return null; @@ -705,15 +1018,25 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s const [selectedInvoice, setSelectedInvoice] = useState(null); const [showIssueForm, setShowIssueForm] = useState(false); const [apiLogs, setApiLogs] = useState([]); + const [supplier, setSupplier] = useState(INITIAL_SUPPLIER); + const [showSupplierModal, setShowSupplierModal] = useState(false); - // 날짜 필터 상태 (기본: 현재 월) + // 검색 필터 상태: filters = 입력용, appliedFilters = 실제 필터링용 const currentMonth = getMonthDates(0); - const [dateFrom, setDateFrom] = useState(currentMonth.from); - const [dateTo, setDateTo] = useState(currentMonth.to); - - // 정렬 상태 (기본: 작성일자 내림차순) - const [sortColumn, setSortColumn] = useState('supplyDate'); - const [sortDirection, setSortDirection] = useState('desc'); + const defaultFilters = { + dateType: 'supplyDate', + dateFrom: currentMonth.from, + dateTo: currentMonth.to, + bizNo: '', + companyName: '', + status: '', + sortColumn: 'supplyDate', + sortDirection: 'desc', + }; + const [filters, setFilters] = useState(defaultFilters); + const [appliedFilters, setAppliedFilters] = useState(defaultFilters); + const updateFilter = (key, value) => setFilters(prev => ({ ...prev, [key]: value })); + const handleSearch = () => setAppliedFilters({ ...filters }); useEffect(() => { loadInvoices(); @@ -808,65 +1131,53 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s ); } - // 정렬 핸들러 - const handleSort = (column) => { - if (sortColumn === column) { - // 같은 컬럼 클릭 시 정렬 방향 토글 - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - // 다른 컬럼 클릭 시 해당 컬럼으로 변경, 내림차순 기본 - setSortColumn(column); - setSortDirection('desc'); - } - }; - - // 날짜 필터 적용된 송장 목록 + // 필터링 적용 (appliedFilters 기준 - 조회 버튼 클릭 시에만 갱신) + const af = appliedFilters; const filteredInvoices = invoices.filter(invoice => { - const supplyDate = invoice.supplyDate; - if (!supplyDate) return true; - return supplyDate >= dateFrom && supplyDate <= dateTo; + const dateVal = af.dateType === 'sentAt' ? invoice.sentAt : invoice.supplyDate; + if (dateVal) { + if (dateVal < af.dateFrom || dateVal > af.dateTo) return false; + } else if (af.dateType === 'sentAt') { + return false; + } + if (af.bizNo) { + const q = af.bizNo.replace(/-/g, ''); + const sBiz = (invoice.supplierBizno || '').replace(/-/g, ''); + const rBiz = (invoice.recipientBizno || '').replace(/-/g, ''); + if (!sBiz.includes(q) && !rBiz.includes(q)) return false; + } + if (af.companyName) { + const q = af.companyName.toLowerCase(); + const sName = (invoice.supplierName || '').toLowerCase(); + const rName = (invoice.recipientName || '').toLowerCase(); + if (!sName.includes(q) && !rName.includes(q)) return false; + } + if (af.status && invoice.status !== af.status) return false; + return true; }); // 정렬 적용 const sortedInvoices = [...filteredInvoices].sort((a, b) => { - let aVal = a[sortColumn]; - let bVal = b[sortColumn]; - - // null/undefined 처리 + let aVal = a[af.sortColumn]; + let bVal = b[af.sortColumn]; if (aVal == null) aVal = ''; if (bVal == null) bVal = ''; - - // 문자열 비교 if (typeof aVal === 'string' && typeof bVal === 'string') { - const comparison = aVal.localeCompare(bVal, 'ko-KR'); - return sortDirection === 'asc' ? comparison : -comparison; + const cmp = aVal.localeCompare(bVal, 'ko-KR'); + return af.sortDirection === 'asc' ? cmp : -cmp; } - - // 숫자 비교 - if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; - if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; + if (aVal < bVal) return af.sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return af.sortDirection === 'asc' ? 1 : -1; return 0; }); - // 이번 달 버튼 - const handleThisMonth = () => { - const dates = getMonthDates(0); - setDateFrom(dates.from); - setDateTo(dates.to); - }; - - // 지난달 버튼 - const handleLastMonth = () => { - const dates = getMonthDates(-1); - setDateFrom(dates.from); - setDateTo(dates.to); - }; - const stats = { total: filteredInvoices.length, issued: filteredInvoices.filter(i => i.status === 'issued' || i.status === 'sent').length, sent: filteredInvoices.filter(i => i.status === 'sent').length, - totalAmount: filteredInvoices.reduce((sum, i) => sum + (i.total || 0), 0) + totalAmount: filteredInvoices.reduce((sum, i) => sum + (i.total || 0), 0), + totalSupplyAmt: filteredInvoices.reduce((sum, i) => sum + (i.totalSupplyAmt || 0), 0), + totalVat: filteredInvoices.reduce((sum, i) => sum + (i.totalVat || 0), 0), }; return ( @@ -896,12 +1207,29 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s - {/* Dashboard */} -
- } /> - } /> - } /> - } /> + {/* Summary Bar */} +
+
월/일 품목명 수량 단가
{item.month && item.day ? `${item.month}/${item.day}` : '-'} {item.name} {item.qty} {formatCurrency(item.unitPrice)}
합계합계 {formatCurrency(invoice.totalSupplyAmt)} {formatCurrency(invoice.totalVat)}
총 합계총 합계 {formatCurrency(invoice.total)}
+ + + + + + + + + + + + +
발행건수 + {stats.total.toLocaleString()} + + | + 발행 {stats.issued.toLocaleString()} + / + 전송 {stats.sent.toLocaleString()} + 총 합계금액{stats.totalAmount.toLocaleString()}총 공급가액{stats.totalSupplyAmt.toLocaleString()}총 세액{stats.totalVat.toLocaleString()}
{/* Issue Form Section */} @@ -910,6 +1238,10 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s

전자세금계산서 발행 +

{!showIssueForm && ( )}
- {showIssueForm && setShowIssueForm(false)} />} + {showIssueForm && setShowIssueForm(false)} supplier={supplier} />}
{/* Invoice List */} @@ -927,16 +1259,10 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s onViewDetail={setSelectedInvoice} onCheckStatus={handleCheckStatus} onDelete={handleDelete} - dateFrom={dateFrom} - dateTo={dateTo} - onDateFromChange={setDateFrom} - onDateToChange={setDateTo} - onThisMonth={handleThisMonth} - onLastMonth={handleLastMonth} + filters={filters} + updateFilter={updateFilter} + onSearch={handleSearch} totalCount={invoices.length} - sortColumn={sortColumn} - sortDirection={sortDirection} - onSort={handleSort} /> {/* API Logs */} @@ -944,6 +1270,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s {/* Detail Modal */} {selectedInvoice && setSelectedInvoice(null)} />} + + {/* Supplier Settings Modal */} + {showSupplierModal && setShowSupplierModal(false)} onSaved={(updated) => setSupplier(updated)} />}
); }; diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index 1e6ccc19..32e38af2 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -67,6 +67,11 @@ const API = { sales: '{{ route("barobill.hometax.sales") }}', purchases: '{{ route("barobill.hometax.purchases") }}', + localSales: '{{ route("barobill.hometax.local-sales") }}', + localPurchases: '{{ route("barobill.hometax.local-purchases") }}', + sync: '{{ route("barobill.hometax.sync") }}', + updateMemo: '{{ route("barobill.hometax.update-memo") }}', + toggleChecked: '{{ route("barobill.hometax.toggle-checked") }}', requestCollect: '{{ route("barobill.hometax.request-collect") }}', collectStatus: '{{ route("barobill.hometax.collect-status") }}', export: '{{ route("barobill.hometax.export") }}', @@ -164,7 +169,8 @@ className={`px-6 py-3 text-sm font-medium rounded-lg transition-all ${ loading, type, onExport, - onRequestCollect + onRequestCollect, + summary // 합계 정보 }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); @@ -224,6 +230,8 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded 사업자번호
(주민번호) 과세
형태 공급가액 + 세액 + 합계 영수
청구 문서
형태 발급
형태 @@ -233,7 +241,7 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded {invoices.length === 0 ? ( - + 해당 기간에 조회된 세금계산서가 없습니다. @@ -278,6 +286,14 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded {formatCurrency(inv.supplyAmount)} + {/* 세액 */} + + {formatCurrency(inv.taxAmount)} + + {/* 합계 */} + + {formatCurrency(inv.totalAmount)} + {/* 영수청구 */} {inv.purposeTypeName || '-'} @@ -298,6 +314,24 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded ); }) )} + {/* 합계행 */} + {invoices.length > 0 && summary && ( + + + 합계 ({invoices.length}건) + + + {formatCurrency(summary.supplyAmount)} + + + {formatCurrency(summary.taxAmount)} + + + {formatCurrency(summary.totalAmount)} + + + + )}
@@ -320,6 +354,10 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded const [dateTo, setDateTo] = useState(currentMonth.to); const [dateType, setDateType] = useState('write'); // 'write': 작성일자, 'issue': 발급일자 const [searchCorpName, setSearchCorpName] = useState(''); // 거래처 검색 + const [dataSource, setDataSource] = useState('local'); // 'local': 로컬 DB, 'api': 바로빌 API + const [syncing, setSyncing] = useState(false); // 동기화 중 여부 + const [lastSyncAt, setLastSyncAt] = useState({ sales: null, purchase: null }); // 마지막 동기화 시간 + const [selectedPeriod, setSelectedPeriod] = useState(null); // 선택된 기간 버튼 ('q1', 'q2', 'q3', 'q4', 'h1', 'h2', 'year') // 진단 관련 상태 const [showDiagnoseModal, setShowDiagnoseModal] = useState(false); @@ -367,11 +405,15 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded startDate: dateFrom.replace(/-/g, ''), endDate: dateTo.replace(/-/g, ''), dateType: dateTypeCode, - limit: 100 + searchCorp: searchCorpName, + limit: 500 }); + // 데이터소스에 따라 API 선택 + const apiUrl = dataSource === 'local' ? API.localSales : API.sales; + try { - const res = await fetch(`${API.sales}?${params}`); + const res = await fetch(`${apiUrl}?${params}`); const json = await res.json(); if (json.success) { @@ -381,6 +423,10 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded pagination: json.data?.pagination || {}, loaded: true }); + // 마지막 동기화 시간 업데이트 + if (json.lastSyncAt) { + setLastSyncAt(prev => ({ ...prev, sales: json.lastSyncAt })); + } // 마지막 수집 시간 갱신 loadCollectStatus(); } else { @@ -404,11 +450,15 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded startDate: dateFrom.replace(/-/g, ''), endDate: dateTo.replace(/-/g, ''), dateType: dateTypeCode, - limit: 100 + searchCorp: searchCorpName, + limit: 500 }); + // 데이터소스에 따라 API 선택 + const apiUrl = dataSource === 'local' ? API.localPurchases : API.purchases; + try { - const res = await fetch(`${API.purchases}?${params}`); + const res = await fetch(`${apiUrl}?${params}`); const json = await res.json(); if (json.success) { @@ -418,6 +468,10 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded pagination: json.data?.pagination || {}, loaded: true }); + // 마지막 동기화 시간 업데이트 + if (json.lastSyncAt) { + setLastSyncAt(prev => ({ ...prev, purchase: json.lastSyncAt })); + } // 마지막 수집 시간 갱신 loadCollectStatus(); } else { @@ -439,6 +493,47 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded } }; + // 바로빌 API에서 로컬 DB로 동기화 + const handleSync = async () => { + if (!confirm('바로빌에서 데이터를 가져와 로컬 DB에 저장합니다.\n계속하시겠습니까?')) return; + + setSyncing(true); + setError(null); + + const dateTypeCode = dateType === 'write' ? 1 : 2; + + try { + const res = await fetch(API.sync, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN + }, + body: JSON.stringify({ + type: 'all', + startDate: dateFrom.replace(/-/g, ''), + endDate: dateTo.replace(/-/g, ''), + dateType: dateTypeCode + }) + }); + + const data = await res.json(); + if (data.success) { + notify(data.message, 'success'); + // 동기화 후 데이터 다시 로드 + setSalesData(prev => ({ ...prev, loaded: false })); + setPurchaseData(prev => ({ ...prev, loaded: false })); + loadCurrentTabData(); + } else { + notify(data.error || '동기화 실패', 'error'); + } + } catch (err) { + notify('동기화 오류: ' + err.message, 'error'); + } finally { + setSyncing(false); + } + }; + const loadCollectStatus = async () => { try { const res = await fetch(API.collectStatus); @@ -582,6 +677,7 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded const lastDay = new Date(year, startMonth + 3, 0); setDateFrom(formatKoreanDate(firstDay)); setDateTo(formatKoreanDate(lastDay)); + setSelectedPeriod('q' + quarter); }; // 기(반기) 계산 (1기: 1-6월, 2기: 7-12월) @@ -593,6 +689,7 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded const lastDay = new Date(year, startMonth + 6, 0); setDateFrom(formatKoreanDate(firstDay)); setDateTo(formatKoreanDate(lastDay)); + setSelectedPeriod('h' + half); }; // 1년 계산 @@ -603,6 +700,7 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded const lastDay = new Date(year, 11, 31); setDateFrom(formatKoreanDate(firstDay)); setDateTo(formatKoreanDate(lastDay)); + setSelectedPeriod('year'); }; // 거래처 필터링 @@ -617,10 +715,41 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded }; const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원'; + const formatNumber = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); + + // 과세/면세 합계 계산 + const calculateTaxTypeSummary = (invoices) => { + let taxableAmount = 0; // 과세 공급가액 + let exemptAmount = 0; // 면세 공급가액 + let taxableCount = 0; + let exemptCount = 0; + + invoices.forEach(inv => { + const amount = inv.supplyAmount || 0; + if (inv.taxTypeName === '면세') { + exemptAmount += amount; + exemptCount++; + } else { + taxableAmount += amount; + taxableCount++; + } + }); + + return { taxableAmount, exemptAmount, taxableCount, exemptCount }; + }; const currentData = activeTab === 'sales' ? salesData : purchaseData; const filteredInvoices = filterByCorpName(currentData.invoices); + // 매입 과세/면세 합계 + const purchaseTaxSummary = calculateTaxTypeSummary(purchaseData.invoices); + // 현재 탭 인보이스 합계 (테이블 합계용) + const currentInvoiceSummary = { + supplyAmount: filteredInvoices.reduce((sum, inv) => sum + (inv.supplyAmount || 0), 0), + taxAmount: filteredInvoices.reduce((sum, inv) => sum + (inv.taxAmount || 0), 0), + totalAmount: filteredInvoices.reduce((sum, inv) => sum + (inv.totalAmount || 0), 0), + }; + return (
{/* Page Header */} @@ -674,26 +803,47 @@ className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:bord setDateFrom(e.target.value)} + onChange={(e) => { setDateFrom(e.target.value); setSelectedPeriod(null); }} className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:border-[#86b7fe] focus:ring-2 focus:ring-[#86b7fe]/25 outline-none" /> ~ setDateTo(e.target.value)} + onChange={(e) => { setDateTo(e.target.value); setSelectedPeriod(null); }} className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:border-[#86b7fe] focus:ring-2 focus:ring-[#86b7fe]/25 outline-none" />
{/* 분기/기/년 버튼 그룹 */}
- - - - - - - + + + + + + +
{/* 검색 버튼 */} +
+ {lastSyncAt[activeTab] && ( + + 마지막 저장: {lastSyncAt[activeTab]} + + )} +
{/* 현재 조회 결과 */}
@@ -759,7 +961,7 @@ className="flex-1 max-w-md px-3 py-2 text-sm border border-[#ced4da] rounded bg- )} {/* Dashboard */} -
+
} color="red" /> + } + color="purple" + />

전자세금계산서

-

전자세금계산서 발행 및 조회

+

전자세금계산서 발행

+
+
+ +
-
-
-
- - - +
+ {{-- 공급자 / 공급받는자 영역 --}} +
+
+ {{-- 공급자 (왼쪽 - 분홍색) --}} +
+ + + {{-- 등록번호 / 종사업장 --}} + + + + + + + + {{-- 상호 / 성명 --}} + + + + + + + {{-- 사업장주소 --}} + + + + + {{-- 업태 / 종목 --}} + + + + + + + {{-- 담당자 / 연락처 --}} + + + + + + + {{-- 이메일 --}} + + + + + +
+ 공급자 + 등록번호 + + 종사업장 + +
상호 + + 성명 + +
사업장
주소
+ +
업태 + + 종목 + +
담당자 + + 연락처 + +
이메일 + +
+
+ + {{-- 공급받는자 (오른쪽 - 파란색) --}} +
+ + + {{-- 등록번호 / 종사업장 --}} + + + + + + + + {{-- 상호 / 성명 --}} + + + + + + + {{-- 사업장주소 --}} + + + + + {{-- 업태 / 종목 --}} + + + + + + + {{-- 담당자 / 연락처 --}} + + + + + + + {{-- 이메일 --}} + + + + + +
+ 공급받는자 + 등록번호 +
+ + +
+
종사업장 + +
상호 + + 성명 + +
사업장
주소
+ +
업태 + + 종목 + +
담당자 + + 연락처 + +
+
+ + + + 이메일 +
+
+ +
+
-

준비중입니다

-

전자세금계산서 기능이 곧 제공됩니다.

-
+ + {{-- 세금계산서 상세 영역 (추후 구현) --}} +
+
+

세금계산서 품목 및 금액 입력 영역

+

(추후 구현 예정)

+
+
+
+ + + +@push('scripts') + +@endpush @endsection diff --git a/resources/views/barobill/usage/index.blade.php b/resources/views/barobill/usage/index.blade.php index 9448f50e..2cd24ace 100644 --- a/resources/views/barobill/usage/index.blade.php +++ b/resources/views/barobill/usage/index.blade.php @@ -60,6 +60,7 @@

사용량조회

바로빌 서비스별 사용량 및 과금 현황을 조회합니다

+

* 운영서버를 사용하는 고객만 표시됩니다 (테스트 모드 제외)

- - {/* 상세 필드 - 토글로 표시 */} - {showDetail && ( -
-
-
setFormData(prev => ({ ...prev, rent_company_tel: e.target.value }))} placeholder="연락처" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
setFormData(prev => ({ ...prev, agreed_mileage: e.target.value }))} placeholder="km" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
-
-
setFormData(prev => ({ ...prev, vehicle_price: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
-
setFormData(prev => ({ ...prev, residual_value: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
-
-
setFormData(prev => ({ ...prev, deposit: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
-
-
setFormData(prev => ({ ...prev, insurance_company: e.target.value }))} placeholder="보험사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
setFormData(prev => ({ ...prev, insurance_company_tel: e.target.value }))} placeholder="연락처" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
-
- )}
- )} - - {/* 법인차량 전용 필드 */} - {formData.ownership_type === 'corporate' && ( -
-
setFormData(prev => ({ ...prev, purchase_price: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
-
setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
-
- )} - - {/* 렌트/리스 차량 주행거리 */} - {(formData.ownership_type === 'rent' || formData.ownership_type === 'lease') && ( -
setFormData(prev => ({ ...prev, mileage: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
- )} +
setFormData(prev => ({ ...prev, driver: e.target.value }))} placeholder="운전자명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
diff --git a/resources/views/finance/fund-schedules/index.blade.php b/resources/views/finance/fund-schedules/index.blade.php index 5ad900f3..3029f30f 100644 --- a/resources/views/finance/fund-schedules/index.blade.php +++ b/resources/views/finance/fund-schedules/index.blade.php @@ -11,13 +11,13 @@

{{ $year }}년 {{ $month }}월 현재

@@ -143,4 +143,329 @@ class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
+ +{{-- 일정 편집/등록 모달 --}} + @endsection + +@push('scripts') + +@endpush diff --git a/resources/views/finance/fund-schedules/partials/calendar.blade.php b/resources/views/finance/fund-schedules/partials/calendar.blade.php index 18b74d2f..8d142b90 100644 --- a/resources/views/finance/fund-schedules/partials/calendar.blade.php +++ b/resources/views/finance/fund-schedules/partials/calendar.blade.php @@ -55,21 +55,21 @@
@if($isCurrentMonth && count($daySchedules) === 0) - - + @endif {{-- 일정 목록 --}}
@foreach($daySchedules as $schedule) - - + @endforeach @if($isCurrentMonth && count($daySchedules) > 0) - - + @endif
diff --git a/resources/views/finance/vehicle-logs.blade.php b/resources/views/finance/vehicle-logs.blade.php index 2b4e617a..b2dfdaa0 100644 --- a/resources/views/finance/vehicle-logs.blade.php +++ b/resources/views/finance/vehicle-logs.blade.php @@ -17,13 +17,6 @@ - @verbatim @endverbatim @endpush diff --git a/resources/views/finance/vehicle-maintenance.blade.php b/resources/views/finance/vehicle-maintenance.blade.php index ed0af9ef..883a4a78 100644 --- a/resources/views/finance/vehicle-maintenance.blade.php +++ b/resources/views/finance/vehicle-maintenance.blade.php @@ -1,6 +1,6 @@ @extends('layouts.app') -@section('title', '법인차량 관리') +@section('title', '차량정비이력') @push('styles')