From 3e12ecf50d2fcf89a6ba40560191c2f77ce2215f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 16:53:48 +0900 Subject: [PATCH 01/61] =?UTF-8?q?feat:=ED=99=88=ED=83=9D=EC=8A=A4=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=EC=84=B8=EC=95=A1/?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공급가액 옆에 세액, 합계 컬럼 추가 - 합계는 파란색 볼드로 강조 표시 - colSpan 11 → 13으로 조정 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/hometax/index.blade.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index 1e6ccc19..915463e2 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -224,6 +224,8 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded 사업자번호
(주민번호) 과세
형태 공급가액 + 세액 + 합계 영수
청구 문서
형태 발급
형태 @@ -233,7 +235,7 @@ className="flex items-center gap-2 px-3 py-1.5 bg-blue-100 text-blue-700 rounded {invoices.length === 0 ? ( - + 해당 기간에 조회된 세금계산서가 없습니다. @@ -278,6 +280,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 || '-'} From ac66e36294d9de8a7b0fdd156bbcdaa50069ade0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 17:13:18 +0900 Subject: [PATCH 02/61] =?UTF-8?q?feat:=ED=99=88=ED=83=9D=EC=8A=A4=20?= =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0=EC=84=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HometaxInvoice 모델 생성 (로컬 DB 조회/저장) - HometaxSyncService 서비스 생성 (API 데이터 동기화) - HometaxController에 로컬 조회/동기화 메서드 추가 - 라우트 추가: local-sales, local-purchases, sync, update-memo, toggle-checked - UI: 데이터소스 선택 (로컬 DB/바로빌 API), 동기화 버튼 추가 Co-Authored-By: Claude Opus 4.5 --- .../Barobill/HometaxController.php | 227 +++++++++++++++ app/Models/Barobill/HometaxInvoice.php | 266 ++++++++++++++++++ app/Services/Barobill/HometaxSyncService.php | 229 +++++++++++++++ .../views/barobill/hometax/index.blade.php | 124 +++++++- routes/web.php | 6 + 5 files changed, 847 insertions(+), 5 deletions(-) create mode 100644 app/Models/Barobill/HometaxInvoice.php create mode 100644 app/Services/Barobill/HometaxSyncService.php 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/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/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/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index 915463e2..57477a6f 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") }}', @@ -330,6 +335,9 @@ 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 [showDiagnoseModal, setShowDiagnoseModal] = useState(false); @@ -377,11 +385,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) { @@ -391,6 +403,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 { @@ -414,11 +430,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) { @@ -428,6 +448,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 { @@ -449,6 +473,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); @@ -716,7 +781,7 @@ className="ml-auto px-5 py-2 text-sm bg-[#0d6efd] text-white rounded hover:bg-[# {/* 거래처 행 */} -
+
(사업자번호 또는 사업자명)
+ {/* 데이터소스 및 동기화 행 */} +
+ +
+ + +
+ + {lastSyncAt[activeTab] && ( + + 마지막 동기화: {lastSyncAt[activeTab]} + + )} +
{/* 현재 조회 결과 */}
diff --git a/routes/web.php b/routes/web.php index 0dfb547b..ac6fe633 100644 --- a/routes/web.php +++ b/routes/web.php @@ -474,6 +474,12 @@ Route::get('/scrap-url', [\App\Http\Controllers\Barobill\HometaxController::class, 'getScrapRequestUrl'])->name('scrap-url'); Route::post('/refresh-scrap', [\App\Http\Controllers\Barobill\HometaxController::class, 'refreshScrap'])->name('refresh-scrap'); Route::get('/diagnose', [\App\Http\Controllers\Barobill\HometaxController::class, 'diagnose'])->name('diagnose'); + // 로컬 DB 조회 및 동기화 + Route::get('/local-sales', [\App\Http\Controllers\Barobill\HometaxController::class, 'localSales'])->name('local-sales'); + Route::get('/local-purchases', [\App\Http\Controllers\Barobill\HometaxController::class, 'localPurchases'])->name('local-purchases'); + Route::post('/sync', [\App\Http\Controllers\Barobill\HometaxController::class, 'sync'])->name('sync'); + Route::post('/update-memo', [\App\Http\Controllers\Barobill\HometaxController::class, 'updateMemo'])->name('update-memo'); + Route::post('/toggle-checked', [\App\Http\Controllers\Barobill\HometaxController::class, 'toggleChecked'])->name('toggle-checked'); }); }); From dff4384a7d6b63c910850ef637f1ecdb48ed8e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 17:21:44 +0900 Subject: [PATCH 03/61] =?UTF-8?q?fix:=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=9C=84=EC=B9=98=EB=A5=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=B2=84=ED=8A=BC=20=EC=98=86=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색기간 행 오른쪽에 [동기화][검색] 버튼 배치 - 데이터 행에서 중복 버튼 제거 - 마지막 동기화 시간은 데이터 행에 유지 Co-Authored-By: Claude Opus 4.5 --- .../views/barobill/hometax/index.blade.php | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index 57477a6f..c9db45b1 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -770,15 +770,32 @@ className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:bord
- {/* 검색 버튼 */} - + {/* 동기화 + 검색 버튼 */} +
+ + +
{/* 거래처 행 */}
@@ -792,7 +809,7 @@ className="flex-1 max-w-md px-3 py-2 text-sm border border-[#ced4da] rounded bg- /> (사업자번호 또는 사업자명)
- {/* 데이터소스 및 동기화 행 */} + {/* 데이터소스 행 */}
@@ -821,22 +838,8 @@ className="w-4 h-4 text-[#0d6efd]" (실시간)
- {lastSyncAt[activeTab] && ( - + 마지막 동기화: {lastSyncAt[activeTab]} )} From b617fbff197ee4cc1358eaef3be1969851fc60a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 17:25:04 +0900 Subject: [PATCH 04/61] =?UTF-8?q?fix:=EC=A0=80=EC=9E=A5=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9D=84=20=EB=B0=94=EB=A1=9C=EB=B9=8C=20API=20?= =?UTF-8?q?=EC=98=86=EC=97=90=20=EC=A3=BC=ED=99=A9=EC=83=89=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 행의 바로빌 API (실시간) 옆에 저장 버튼 추가 - 주황색(#fd7e14)으로 눈에 띄게 표시 - 저장 아이콘으로 변경 - "마지막 동기화" → "마지막 저장"으로 텍스트 변경 Co-Authored-By: Claude Opus 4.5 --- .../views/barobill/hometax/index.blade.php | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index c9db45b1..0c5d3f2b 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -770,32 +770,15 @@ className="px-3 py-2 text-sm border border-[#ced4da] rounded bg-white focus:bord
- {/* 동기화 + 검색 버튼 */} -
- - -
+ {/* 검색 버튼 */} +
{/* 거래처 행 */}
@@ -837,10 +820,26 @@ className="w-4 h-4 text-[#0d6efd]" 바로빌 API (실시간) + {/* 저장 버튼 - 주황색으로 눈에 띄게 */} +
{lastSyncAt[activeTab] && ( - 마지막 동기화: {lastSyncAt[activeTab]} + 마지막 저장: {lastSyncAt[activeTab]} )} From f9065fae52c56ffdcd3af06ed987699ca85e3802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 17:29:02 +0900 Subject: [PATCH 05/61] =?UTF-8?q?fix:=ED=99=88=ED=83=9D=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=B2=84=ED=8A=BC=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A1=9C=20=EC=83=89?= =?UTF-8?q?=EC=83=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/barobill/hometax/index.blade.php | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/resources/views/barobill/hometax/index.blade.php b/resources/views/barobill/hometax/index.blade.php index 0c5d3f2b..127e1f05 100644 --- a/resources/views/barobill/hometax/index.blade.php +++ b/resources/views/barobill/hometax/index.blade.php @@ -820,17 +820,37 @@ className="w-4 h-4 text-[#0d6efd]" 바로빌 API (실시간) - {/* 저장 버튼 - 주황색으로 눈에 띄게 */} + {/* 저장 버튼 - 인라인 스타일로 확실하게 주황색 적용 */} - - - - - - + + + + + + + {/* 검색 버튼 */} - - {/* 상세 필드 - 토글로 표시 */} - {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" />
-
+ {/* 상세 필드 */} +
+
+
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" />
+
+
)} From 3cb31abfcde41a9658d1169b7b6268720687b27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 19:22:43 +0900 Subject: [PATCH 13/61] =?UTF-8?q?fix:=EC=B0=A8=EB=9F=89=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D/=EC=88=98=EC=A0=95=20setShowDetail=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/views/finance/corporate-vehicles.blade.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/views/finance/corporate-vehicles.blade.php b/resources/views/finance/corporate-vehicles.blade.php index 22813249..1eb7c110 100644 --- a/resources/views/finance/corporate-vehicles.blade.php +++ b/resources/views/finance/corporate-vehicles.blade.php @@ -146,11 +146,10 @@ function CorporateVehiclesManagement() { const rentLeaseVehicles = vehicles.filter(v => v.ownership_type === 'rent' || v.ownership_type === 'lease'); const totalMonthlyRent = rentLeaseVehicles.reduce((sum, v) => sum + (v.monthly_rent || 0) + (v.monthly_rent_tax || 0), 0); - const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowDetail(false); setShowModal(true); }; + const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); }; const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); - setShowDetail(false); setFormData({ plate_number: item.plate_number || '', model: item.model || '', From f8aa3561119d8faecd8741859b56313921ea2c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 19:35:51 +0900 Subject: [PATCH 14/61] =?UTF-8?q?fix:=EC=B0=A8=EB=9F=89=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=8F=BC=20=EB=AA=A8=EB=93=A0=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../finance/corporate-vehicles.blade.php | 70 +++++++------------ 1 file changed, 25 insertions(+), 45 deletions(-) diff --git a/resources/views/finance/corporate-vehicles.blade.php b/resources/views/finance/corporate-vehicles.blade.php index 1eb7c110..c8cc82ed 100644 --- a/resources/views/finance/corporate-vehicles.blade.php +++ b/resources/views/finance/corporate-vehicles.blade.php @@ -429,58 +429,38 @@ function CorporateVehiclesManagement() {
setFormData(prev => ({ ...prev, model: e.target.value }))} placeholder="차량 모델명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
setFormData(prev => ({ ...prev, year: parseInt(e.target.value) }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
- {formData.ownership_type === 'corporate' && ( -
setFormData(prev => ({ ...prev, purchase_date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
- )} - {(formData.ownership_type === 'rent' || formData.ownership_type === 'lease') && ( -
setFormData(prev => ({ ...prev, contract_date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
- )} +
setFormData(prev => ({ ...prev, [formData.ownership_type === 'corporate' ? 'purchase_date' : 'contract_date']: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
- {/* 렌트/리스 전용 필드 */} - {(formData.ownership_type === 'rent' || formData.ownership_type === 'lease') && ( -
- {/* 핵심 필드 - 항상 표시 */} -
-
setFormData(prev => ({ ...prev, rent_company: e.target.value }))} placeholder="회사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
-
setFormData(prev => ({ ...prev, rent_period: e.target.value }))} placeholder="예: 36개월" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+ {/* 공통 필드 */} +
+
+
setFormData(prev => ({ ...prev, rent_company: e.target.value }))} placeholder="회사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
setFormData(prev => ({ ...prev, rent_period: e.target.value }))} placeholder="예: 36개월" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
+
+
+
setFormData(prev => ({ ...prev, [formData.ownership_type === 'corporate' ? 'purchase_price' : 'monthly_rent']: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
+
setFormData(prev => ({ ...prev, monthly_rent_tax: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
+
+ + {/* 상세 필드 */} +
+
+
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, monthly_rent: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
-
setFormData(prev => ({ ...prev, monthly_rent_tax: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" />
+
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, 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" />
-
+
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" />
+
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 === '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" />
From e7e2c1d515653a9ea1a112dffeda00f32c030695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 19:37:56 +0900 Subject: [PATCH 15/61] =?UTF-8?q?fix:=EC=B0=A8=EB=9F=89=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=B4=EC=A6=9D=EA=B8=88=EA=B3=BC=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EC=A3=BC=ED=96=89=EA=B1=B0=EB=A6=AC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=ED=96=89=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/views/finance/corporate-vehicles.blade.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/resources/views/finance/corporate-vehicles.blade.php b/resources/views/finance/corporate-vehicles.blade.php index c8cc82ed..256cebd0 100644 --- a/resources/views/finance/corporate-vehicles.blade.php +++ b/resources/views/finance/corporate-vehicles.blade.php @@ -453,12 +453,14 @@ function CorporateVehiclesManagement() {
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, 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, 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, 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" />
-
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" />
From fe15cecbdbe2981eb3f3d5d6daba861a8c3b51e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 19:56:44 +0900 Subject: [PATCH 16/61] =?UTF-8?q?feat:=EC=B0=A8=EB=9F=89=EC=A0=95=EB=B9=84?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=8B=A4=EC=A0=9C=20DB=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Finance/VehicleMaintenanceController.php | 200 +++++++++++++++ app/Models/VehicleMaintenance.php | 55 +++++ .../finance/vehicle-maintenance.blade.php | 228 ++++++++++++++---- routes/web.php | 14 +- 4 files changed, 448 insertions(+), 49 deletions(-) create mode 100644 app/Http/Controllers/Finance/VehicleMaintenanceController.php create mode 100644 app/Models/VehicleMaintenance.php 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/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/resources/views/finance/vehicle-maintenance.blade.php b/resources/views/finance/vehicle-maintenance.blade.php index ed0af9ef..b48fc91f 100644 --- a/resources/views/finance/vehicle-maintenance.blade.php +++ b/resources/views/finance/vehicle-maintenance.blade.php @@ -48,23 +48,64 @@ function VehicleMaintenanceManagement() { // 탭 상태 const [activeTab, setActiveTab] = useState('maintenance'); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); - // 차량 목록 (동적 관리) - const [vehicles, setVehicles] = useState([ - { id: 1, plateNumber: '12가 3456', model: '제네시스 G80', ownershipType: 'corporate', purchaseDate: '2023-05-15', purchasePrice: '', currentMileage: 15000 }, - { id: 2, plateNumber: '34나 5678', model: '현대 스타렉스', ownershipType: 'corporate', purchaseDate: '2022-03-10', purchasePrice: '', currentMileage: 48000 }, - { id: 3, plateNumber: '56다 7890', model: '기아 레이', ownershipType: 'rent', contractDate: '2024-01-01', rentCompany: '롯데렌터카', rentCompanyTel: '1588-1234', rentPeriod: '36개월', agreedMileage: '30000', vehiclePrice: '25000000', residualValue: '15000000', deposit: '3000000', monthlyRent: '450000', monthlyRentTax: '45000', insuranceCompany: '삼성화재', insuranceCompanyTel: '1588-5114', currentMileage: 62000 }, - { id: 4, plateNumber: '78라 1234', model: '포터2', ownershipType: 'lease', contractDate: '2023-06-01', rentCompany: '현대캐피탈', rentCompanyTel: '1588-1234', rentPeriod: '48개월', agreedMileage: '60000', vehiclePrice: '32000000', residualValue: '12000000', deposit: '5000000', monthlyRent: '520000', monthlyRentTax: '52000', insuranceCompany: 'DB손해보험', insuranceCompanyTel: '1588-0100', currentMileage: 95000 }, - ]); + // 차량 목록 (API에서 로드) + const [vehicles, setVehicles] = useState([]); - const [maintenances, setMaintenances] = useState([ - { id: 1, date: '2026-01-20', vehicleId: 1, category: '주유', description: '휘발유', amount: 120000, mileage: 15000, vendor: 'GS칼텍스', memo: '' }, - { id: 2, date: '2026-01-18', vehicleId: 2, category: '주유', description: '경유', amount: 95000, mileage: 48000, vendor: 'SK에너지', memo: '' }, - { id: 3, date: '2026-01-15', vehicleId: 4, category: '정비', description: '엔진오일 교환', amount: 150000, mileage: 95000, vendor: '현대오토', memo: '정기 점검' }, - { id: 4, date: '2026-01-10', vehicleId: 3, category: '보험', description: '자동차보험 연납', amount: 850000, mileage: 62000, vendor: '삼성화재', memo: '2025.01~2026.01' }, - { id: 5, date: '2026-01-05', vehicleId: 1, category: '세차', description: '세차 및 실내크리닝', amount: 50000, mileage: 14500, vendor: '카와시', memo: '' }, - { id: 6, date: '2025-12-20', vehicleId: 2, category: '정비', description: '타이어 교체', amount: 480000, mileage: 45000, vendor: '한국타이어', memo: '4개 교체' }, - ]); + // 정비 이력 (API에서 로드) + const [maintenances, setMaintenances] = useState([]); + + // 데이터 로드 + const loadVehicles = async () => { + try { + const response = await fetch('/finance/vehicle-maintenance/vehicles'); + const result = await response.json(); + if (result.success) { + setVehicles(result.data.map(v => ({ + id: v.id, + plateNumber: v.plate_number, + model: v.model, + ownershipType: v.ownership_type, + currentMileage: v.mileage || 0 + }))); + } + } catch (error) { + console.error('차량 로드 실패:', error); + } + }; + + const loadMaintenances = async () => { + try { + const params = new URLSearchParams({ + start_date: dateRange.start, + end_date: dateRange.end, + category: filterCategory, + vehicle_id: filterVehicle, + search: searchTerm + }); + const response = await fetch(`/finance/vehicle-maintenance/list?${params}`); + const result = await response.json(); + if (result.success) { + setMaintenances(result.data); + } + } catch (error) { + console.error('정비 이력 로드 실패:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadVehicles(); + }, []); + + useEffect(() => { + if (vehicles.length > 0 || !loading) { + loadMaintenances(); + } + }, [dateRange, filterCategory, filterVehicle]); const [searchTerm, setSearchTerm] = useState(''); const [filterCategory, setFilterCategory] = useState('all'); @@ -96,6 +137,14 @@ function VehicleMaintenanceManagement() { return v ? `${v.plateNumber} (${v.model})` : '-'; }; + // API 응답에서 차량 정보 표시 (plateNumber가 직접 포함된 경우) + const getVehicleDisplayFromItem = (item) => { + if (item.plateNumber && item.model) { + return `${item.plateNumber} (${item.model})`; + } + return getVehicleDisplay(item.vehicleId); + }; + const getOwnershipLabel = (type) => { const found = ownershipTypes.find(t => t.value === type); return found ? found.label : type; @@ -154,13 +203,13 @@ function VehicleMaintenanceManagement() { }; const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, ''); + // API에서 이미 필터링된 데이터를 받으므로 클라이언트 측에서는 검색어만 필터링 const filteredMaintenances = maintenances.filter(item => { - const matchesSearch = item.description.toLowerCase().includes(searchTerm.toLowerCase()) || - item.vendor.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesCategory = filterCategory === 'all' || item.category === filterCategory; - const matchesVehicle = filterVehicle === 'all' || item.vehicleId === parseInt(filterVehicle); - const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end; - return matchesSearch && matchesCategory && matchesVehicle && matchesDate; + if (!searchTerm) return true; + const search = searchTerm.toLowerCase(); + return (item.description || '').toLowerCase().includes(search) || + (item.vendor || '').toLowerCase().includes(search) || + (item.plateNumber || '').toLowerCase().includes(search); }); const totalAmount = filteredMaintenances.reduce((sum, item) => sum + item.amount, 0); @@ -171,36 +220,131 @@ function VehicleMaintenanceManagement() { // 유지비 등록/수정 const handleAdd = () => { setModalMode('add'); setFormData({...initialFormState, vehicleId: vehicles[0]?.id || ''}); setShowModal(true); }; const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); }; - const handleSave = () => { + const handleSave = async () => { if (!formData.description || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; } - const amount = parseInt(formData.amount) || 0; - const mileage = parseInt(formData.mileage) || 0; - if (modalMode === 'add') { - setMaintenances(prev => [{ id: Date.now(), ...formData, vehicleId: parseInt(formData.vehicleId), amount, mileage }, ...prev]); - } else { - setMaintenances(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, vehicleId: parseInt(formData.vehicleId), amount, mileage } : item)); + setSaving(true); + try { + const payload = { + vehicle_id: parseInt(formData.vehicleId), + date: formData.date, + category: formData.category, + description: formData.description, + amount: parseInt(String(formData.amount).replace(/[^\d]/g, '')) || 0, + mileage: parseInt(String(formData.mileage).replace(/[^\d]/g, '')) || null, + vendor: formData.vendor, + memo: formData.memo + }; + const url = modalMode === 'add' ? '/finance/vehicle-maintenance' : `/finance/vehicle-maintenance/${editingItem.id}`; + const method = modalMode === 'add' ? 'POST' : 'PUT'; + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }, + body: JSON.stringify(payload) + }); + const result = await response.json(); + if (result.success) { + await loadMaintenances(); + setShowModal(false); + setEditingItem(null); + } else { + alert(result.message || '저장에 실패했습니다.'); + } + } catch (error) { + console.error('저장 오류:', error); + alert('저장 중 오류가 발생했습니다.'); + } finally { + setSaving(false); + } + }; + const handleDelete = async (id) => { + if (!confirm('정말 삭제하시겠습니까?')) return; + try { + const response = await fetch(`/finance/vehicle-maintenance/${id}`, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content } + }); + const result = await response.json(); + if (result.success) { + await loadMaintenances(); + setShowModal(false); + } else { + alert(result.message || '삭제에 실패했습니다.'); + } + } catch (error) { + console.error('삭제 오류:', error); + alert('삭제 중 오류가 발생했습니다.'); } - setShowModal(false); setEditingItem(null); }; - const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setMaintenances(prev => prev.filter(item => item.id !== id)); setShowModal(false); } }; - // 차량 등록/수정 + // 차량 등록/수정 - corporate-vehicles API 사용 const handleAddVehicle = () => { setVehicleModalMode('add'); setVehicleFormData(initialVehicleFormState); setShowVehicleModal(true); }; const handleEditVehicle = (vehicle) => { setVehicleModalMode('edit'); setEditingVehicle(vehicle); setVehicleFormData({ ...vehicle }); setShowVehicleModal(true); }; - const handleSaveVehicle = () => { + const handleSaveVehicle = async () => { if (!vehicleFormData.plateNumber || !vehicleFormData.model) { alert('차량번호와 모델명은 필수입니다.'); return; } - if (vehicleModalMode === 'add') { - setVehicles(prev => [{ id: Date.now(), ...vehicleFormData }, ...prev]); - } else { - setVehicles(prev => prev.map(v => v.id === editingVehicle.id ? { ...v, ...vehicleFormData } : v)); + setSaving(true); + try { + const payload = { + plate_number: vehicleFormData.plateNumber, + model: vehicleFormData.model, + vehicle_type: '승용차', + ownership_type: vehicleFormData.ownershipType, + year: new Date().getFullYear(), + mileage: parseInt(String(vehicleFormData.currentMileage).replace(/[^\d]/g, '')) || 0, + purchase_date: vehicleFormData.purchaseDate, + purchase_price: parseInt(String(vehicleFormData.purchasePrice).replace(/[^\d]/g, '')) || 0, + contract_date: vehicleFormData.contractDate, + rent_company: vehicleFormData.rentCompany, + rent_company_tel: vehicleFormData.rentCompanyTel, + rent_period: vehicleFormData.rentPeriod, + agreed_mileage: vehicleFormData.agreedMileage, + vehicle_price: parseInt(String(vehicleFormData.vehiclePrice).replace(/[^\d]/g, '')) || 0, + residual_value: parseInt(String(vehicleFormData.residualValue).replace(/[^\d]/g, '')) || 0, + deposit: parseInt(String(vehicleFormData.deposit).replace(/[^\d]/g, '')) || 0, + monthly_rent: parseInt(String(vehicleFormData.monthlyRent).replace(/[^\d]/g, '')) || 0, + monthly_rent_tax: parseInt(String(vehicleFormData.monthlyRentTax).replace(/[^\d]/g, '')) || 0, + insurance_company: vehicleFormData.insuranceCompany, + insurance_company_tel: vehicleFormData.insuranceCompanyTel + }; + const url = vehicleModalMode === 'add' ? '/finance/corporate-vehicles' : `/finance/corporate-vehicles/${editingVehicle.id}`; + const method = vehicleModalMode === 'add' ? 'POST' : 'PUT'; + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }, + body: JSON.stringify(payload) + }); + const result = await response.json(); + if (result.success) { + await loadVehicles(); + setShowVehicleModal(false); + setEditingVehicle(null); + } else { + alert(result.message || '저장에 실패했습니다.'); + } + } catch (error) { + console.error('저장 오류:', error); + alert('저장 중 오류가 발생했습니다.'); + } finally { + setSaving(false); } - setShowVehicleModal(false); setEditingVehicle(null); }; - const handleDeleteVehicle = (id) => { - if (confirm('차량을 삭제하시겠습니까? 관련 유지비 기록도 함께 삭제됩니다.')) { - setVehicles(prev => prev.filter(v => v.id !== id)); - setMaintenances(prev => prev.filter(m => m.vehicleId !== id)); - setShowVehicleModal(false); + const handleDeleteVehicle = async (id) => { + if (!confirm('차량을 삭제하시겠습니까? 관련 유지비 기록도 함께 삭제됩니다.')) return; + try { + const response = await fetch(`/finance/corporate-vehicles/${id}`, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content } + }); + const result = await response.json(); + if (result.success) { + await loadVehicles(); + await loadMaintenances(); + setShowVehicleModal(false); + } else { + alert(result.message || '삭제에 실패했습니다.'); + } + } catch (error) { + console.error('삭제 오류:', error); + alert('삭제 중 오류가 발생했습니다.'); } }; diff --git a/routes/web.php b/routes/web.php index ac6fe633..26f0f329 100644 --- a/routes/web.php +++ b/routes/web.php @@ -834,13 +834,13 @@ Route::delete('/vehicle-logs/{id}', [\App\Http\Controllers\Finance\VehicleLogController::class, 'destroy'])->name('vehicle-logs.destroy'); Route::get('/vehicle-logs/export', [\App\Http\Controllers\Finance\VehicleLogController::class, 'export'])->name('vehicle-logs.export'); - Route::get('/vehicle-maintenance', function () { - if (request()->header('HX-Request')) { - return response('', 200)->header('HX-Redirect', route('finance.vehicle-maintenance')); - } - - return view('finance.vehicle-maintenance'); - })->name('vehicle-maintenance'); + // 차량정비이력 + Route::get('/vehicle-maintenance', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'index'])->name('vehicle-maintenance'); + Route::get('/vehicle-maintenance/vehicles', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'vehicles'])->name('vehicle-maintenance.vehicles'); + Route::get('/vehicle-maintenance/list', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'list'])->name('vehicle-maintenance.list'); + Route::post('/vehicle-maintenance', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'store'])->name('vehicle-maintenance.store'); + Route::put('/vehicle-maintenance/{id}', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'update'])->name('vehicle-maintenance.update'); + Route::delete('/vehicle-maintenance/{id}', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'destroy'])->name('vehicle-maintenance.destroy'); // 거래처관리 Route::get('/customers', function () { From 7ee27d7c2afbc4a6dfcef6ff94cff7d1b833c488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 20:01:51 +0900 Subject: [PATCH 17/61] =?UTF-8?q?feat:=EC=B0=A8=EB=9F=89=EC=9D=BC=EC=A7=80?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VehicleLogController: CRUD 및 통계 API 추가 - VehicleLog 모델: 구분/분류 코드 정의 추가 - vehicle-logs.blade.php: React 기반 운행기록부 UI - routes/web.php: vehicles, summary 엔드포인트 추가 Co-Authored-By: Claude Opus 4.5 --- .../Finance/VehicleLogController.php | 256 ++--- app/Models/VehicleLog.php | 93 +- .../views/finance/vehicle-logs.blade.php | 905 ++++++++---------- routes/web.php | 3 +- 4 files changed, 597 insertions(+), 660 deletions(-) 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/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/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/routes/web.php b/routes/web.php index 26f0f329..c2659b6c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -828,11 +828,12 @@ // 차량일지 (운행기록부) Route::get('/vehicle-logs', [\App\Http\Controllers\Finance\VehicleLogController::class, 'index'])->name('vehicle-logs'); + Route::get('/vehicle-logs/vehicles', [\App\Http\Controllers\Finance\VehicleLogController::class, 'vehicles'])->name('vehicle-logs.vehicles'); Route::get('/vehicle-logs/list', [\App\Http\Controllers\Finance\VehicleLogController::class, 'list'])->name('vehicle-logs.list'); + Route::get('/vehicle-logs/summary', [\App\Http\Controllers\Finance\VehicleLogController::class, 'summary'])->name('vehicle-logs.summary'); Route::post('/vehicle-logs', [\App\Http\Controllers\Finance\VehicleLogController::class, 'store'])->name('vehicle-logs.store'); Route::put('/vehicle-logs/{id}', [\App\Http\Controllers\Finance\VehicleLogController::class, 'update'])->name('vehicle-logs.update'); Route::delete('/vehicle-logs/{id}', [\App\Http\Controllers\Finance\VehicleLogController::class, 'destroy'])->name('vehicle-logs.destroy'); - Route::get('/vehicle-logs/export', [\App\Http\Controllers\Finance\VehicleLogController::class, 'export'])->name('vehicle-logs.export'); // 차량정비이력 Route::get('/vehicle-maintenance', [\App\Http\Controllers\Finance\VehicleMaintenanceController::class, 'index'])->name('vehicle-maintenance'); From 6f32dae62281384f5ca14c7f59cb03729c12acc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 3 Feb 2026 20:05:26 +0900 Subject: [PATCH 18/61] =?UTF-8?q?refactor:=EC=B0=A8=EB=9F=89=EC=A0=95?= =?UTF-8?q?=EB=B9=84=EC=9D=B4=EB=A0=A5=EC=97=90=EC=84=9C=20=EC=B0=A8?= =?UTF-8?q?=EB=9F=89=20=EB=93=B1=EB=A1=9D=20=ED=83=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 차량 등록/수정은 차량목록 페이지에서 관리 - 탭 네비게이션 제거, 유지비 관리만 유지 - 타이틀 '법인차량 관리' → '차량정비이력' 변경 Co-Authored-By: Claude Opus 4.5 --- .../finance/vehicle-maintenance.blade.php | 444 ++++-------------- 1 file changed, 83 insertions(+), 361 deletions(-) diff --git a/resources/views/finance/vehicle-maintenance.blade.php b/resources/views/finance/vehicle-maintenance.blade.php index b48fc91f..a53bb6fd 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') + +@push('scripts') + +@endpush @endsection From cd62fbe446c86e64e942066dd0d4548d7d15667b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 10:59:02 +0900 Subject: [PATCH 39/61] =?UTF-8?q?feat:=EC=A0=84=EC=9E=90=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=EA=B3=B5=EA=B8=89=EC=9E=90/?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EB=B0=9B=EB=8A=94=EC=9E=90=20=EC=A2=8C?= =?UTF-8?q?=EC=9A=B0=20grid=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공급자(분홍색)/공급받는자(파란색) 좌우 배치 테이블 형태 - 업태, 종목, 종사업장, 담당자, 연락처 항목 추가 - 운영 모드: 공급받는자/품목 샘플 데이터 미표시 - 테스트 모드: 공급받는자에 랜덤 샘플 데이터 표시 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 293 +++++++++++++----- 1 file changed, 219 insertions(+), 74 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 085bdb41..77265c21 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -76,12 +76,12 @@ const CSRF_TOKEN = '{{ csrf_token() }}'; - // 수취인 사업자 정보 목록 + // 수취인 사업자 정보 목록 (테스트 모드 전용) const RECIPIENT_COMPANIES = [ - { bizno: '311-46-00378', name: '김인태', ceo: '김인태', addr: '인천광역시 부평구 안남로 272, 107동 1704호', contact: '', email: 'test@example.com' }, - { bizno: '107-81-78114', name: '(주)이상네트웍스', ceo: '조원표', addr: '서울특별시 마포구 월드컵북로58길 9', contact: '송덕화 매니져', email: 'test@example.com' }, - { bizno: '843-22-01859', name: '조은지게차', ceo: '유영주', addr: '경기도 김포시 사우중로 5(사우동)', contact: '', email: 'test@example.com' }, - { bizno: '406-05-25709', name: '스카이익스프레스', ceo: '안옥현', addr: '인천광역시 연수구 능허대로79번길 65', contact: '', email: 'test@example.com' } + { bizno: '311-46-00378', name: '김인태', ceo: '김인태', addr: '인천광역시 부평구 안남로 272, 107동 1704호', bizType: '서비스업', bizClass: '운송', contact: '', contactPhone: '', email: 'test@example.com' }, + { bizno: '107-81-78114', name: '(주)이상네트웍스', ceo: '조원표', addr: '서울특별시 마포구 월드컵북로58길 9', bizType: '정보통신업', bizClass: '소프트웨어 개발', contact: '송덕화 매니져', contactPhone: '02-1234-5678', email: 'test@example.com' }, + { bizno: '843-22-01859', name: '조은지게차', ceo: '유영주', addr: '경기도 김포시 사우중로 5(사우동)', bizType: '건설업', bizClass: '중장비 임대', contact: '', contactPhone: '', email: 'test@example.com' }, + { bizno: '406-05-25709', name: '스카이익스프레스', ceo: '안옥현', addr: '인천광역시 연수구 능허대로79번길 65', bizType: '운수업', bizClass: '택배', contact: '', contactPhone: '', email: 'test@example.com' } ]; // 공급자 정보 (현재 테넌트의 바로빌 회원사 정보) @@ -90,7 +90,10 @@ 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_phone ?? "" }}', email: '{{ $barobillMember?->manager_email ?? $currentTenant?->email ?? "" }}' }; @@ -129,33 +132,67 @@ // IssueForm Component const IssueForm = ({ onIssue, onCancel }) => { 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; + 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' }); + } + const randomDaysAgo = Math.floor(Math.random() * 30); + supplyDate = new Date(); + supplyDate.setDate(supplyDate.getDate() - randomDaysAgo); + } else { + // 운영 모드: 빈 품목 1건 + items = [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat' }]; + supplyDate = new Date(); + } + + // 공급받는자: 테스트 모드에서만 샘플 데이터, 운영 모드에서는 비움 + 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, + supplierBizType: FIXED_SUPPLIER.bizType, + supplierBizClass: FIXED_SUPPLIER.bizClass, supplierContact: FIXED_SUPPLIER.contact, + supplierContactPhone: FIXED_SUPPLIER.contactPhone, supplierEmail: FIXED_SUPPLIER.email, - recipientBizno: randomRecipient.bizno, - recipientName: randomRecipient.name, - recipientCeo: randomRecipient.ceo, - recipientAddr: randomRecipient.addr, - recipientContact: randomRecipient.contact || '홍길동', - recipientEmail: randomRecipient.email, + ...recipientData, supplyDate: formatLocalDate(supplyDate), items, memo: '' @@ -201,64 +238,172 @@ 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 />
From 57c6e1c40afb6e92cc1867c8823d7098d333c941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 11:01:12 +0900 Subject: [PATCH 40/61] =?UTF-8?q?fix:Blade=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20style=20=EC=9D=B4=EC=A4=91=20=EC=A4=91=EA=B4=84=ED=98=B8=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/views/barobill/etax/index.blade.php | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 77265c21..09203cd5 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -252,19 +252,19 @@
{/* === 공급자 (왼쪽 - 분홍색) === */}
- +
{/* 등록번호 / 종사업장 */} - - + - - + @@ -321,22 +321,22 @@ {/* === 공급받는자 (오른쪽 - 파란색) === */}
-
+ 공급자 등록번호등록번호 종사업장 + 종사업장
+
{/* 등록번호 / 종사업장 */} - - + - - + From d3154d9ad89f5e8212e6eb3969b21dfbf1ca6892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 11:04:35 +0900 Subject: [PATCH 41/61] =?UTF-8?q?fix:=EC=A0=84=EC=9E=90=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=EA=B3=B5=EA=B8=89=EC=9E=90/?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EB=B0=9B=EB=8A=94=EC=9E=90=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=97=B4=20=EB=B9=84=EC=9C=A8=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/views/barobill/etax/index.blade.php | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 09203cd5..1c287952 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -253,18 +253,25 @@ {/* === 공급자 (왼쪽 - 분홍색) === */}
+ 공급받는자 등록번호등록번호
setFormData({ ...formData, recipientBizno: e.target.value })} required />
종사업장 + 종사업장
+ + + + + + + {/* 등록번호 / 종사업장 */} - - + - - + @@ -322,21 +329,28 @@ {/* === 공급받는자 (오른쪽 - 파란색) === */}
+ 공급자 등록번호등록번호 종사업장 + 종사업장
+ + + + + + + {/* 등록번호 / 종사업장 */} - - + - - + From 5a11622e38c96be1a82c8c3b714b86c1d2e55eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 11:09:16 +0900 Subject: [PATCH 42/61] =?UTF-8?q?fix:=EC=A0=84=EC=9E=90=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=97=B4=20=EB=B9=84=EC=9C=A8=20=EA=B7=A0=ED=98=95=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=20(5:11:39:11:34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/views/barobill/etax/index.blade.php | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 1c287952..7f3b373a 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -254,11 +254,11 @@
+ 공급받는자 등록번호등록번호
setFormData({ ...formData, recipientBizno: e.target.value })} required />
종사업장 + 종사업장
- - - - - + + + + + {/* 등록번호 / 종사업장 */} @@ -330,11 +330,11 @@
- - - - - + + + + + {/* 등록번호 / 종사업장 */} From 95df5cb66973c5c92d44f2bf0480e444e0879bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 11:22:20 +0900 Subject: [PATCH 43/61] =?UTF-8?q?feat:=EC=A0=84=EC=9E=90=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=EA=B3=B5=EA=B8=89=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=EC=A0=95=EB=B3=B4=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EtaxController에 getSupplier/updateSupplier 메서드 추가 - etax 라우트 그룹에 GET/POST /supplier 라우트 추가 - SupplierSettingsModal React 컴포넌트 구현 (톱니바퀴 아이콘) - IssueForm이 supplier state를 props로 참조하도록 변경 - manager_phone → manager_hp 필드명 버그 수정 - FIXED_SUPPLIER → INITIAL_SUPPLIER 상수 리네이밍 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Barobill/EtaxController.php | 76 +++++++++ resources/views/barobill/etax/index.blade.php | 157 ++++++++++++++++-- routes/web.php | 2 + 3 files changed, 222 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/Barobill/EtaxController.php b/app/Http/Controllers/Barobill/EtaxController.php index 8fc60f55..29aadccc 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 ?? '', + ], + ]); + } + /** * 세금계산서 삭제 */ diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 7f3b373a..fc6c6e04 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -72,6 +72,8 @@ issue: '{{ route("barobill.etax.issue") }}', sendToNts: '{{ route("barobill.etax.send-to-nts") }}', delete: '{{ route("barobill.etax.delete") }}', + supplier: '{{ route("barobill.etax.supplier") }}', + supplierUpdate: '{{ route("barobill.etax.supplier.update") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -85,7 +87,7 @@ ]; // 공급자 정보 (현재 테넌트의 바로빌 회원사 정보) - const FIXED_SUPPLIER = { + const INITIAL_SUPPLIER = { bizno: '{{ $barobillMember?->biz_no ?? "" }}', name: '{{ $barobillMember?->corp_name ?? $currentTenant?->company_name ?? "" }}', ceo: '{{ $barobillMember?->ceo_name ?? $currentTenant?->ceo_name ?? "" }}', @@ -93,7 +95,7 @@ bizType: '{{ $barobillMember?->biz_type ?? "" }}', bizClass: '{{ $barobillMember?->biz_class ?? "" }}', contact: '{{ $barobillMember?->manager_name ?? "" }}', - contactPhone: '{{ $barobillMember?->manager_phone ?? "" }}', + contactPhone: '{{ $barobillMember?->manager_hp ?? "" }}', email: '{{ $barobillMember?->manager_email ?? $currentTenant?->email ?? "" }}' }; @@ -130,7 +132,7 @@ ); // IssueForm Component - const IssueForm = ({ onIssue, onCancel }) => { + const IssueForm = ({ onIssue, onCancel, supplier }) => { const generateRandomData = () => { let items = []; let supplyDate; @@ -183,15 +185,15 @@ } return { - supplierBizno: FIXED_SUPPLIER.bizno, - supplierName: FIXED_SUPPLIER.name, - supplierCeo: FIXED_SUPPLIER.ceo, - supplierAddr: FIXED_SUPPLIER.addr, - supplierBizType: FIXED_SUPPLIER.bizType, - supplierBizClass: FIXED_SUPPLIER.bizClass, - supplierContact: FIXED_SUPPLIER.contact, - supplierContactPhone: FIXED_SUPPLIER.contactPhone, - supplierEmail: FIXED_SUPPLIER.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, @@ -808,6 +810,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; @@ -864,6 +987,8 @@ 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); // 날짜 필터 상태 (기본: 현재 월) const currentMonth = getMonthDates(0); @@ -1069,6 +1194,9 @@ 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 */} @@ -1103,6 +1231,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/routes/web.php b/routes/web.php index 98744f9a..52d7c953 100644 --- a/routes/web.php +++ b/routes/web.php @@ -432,6 +432,8 @@ Route::post('/issue', [\App\Http\Controllers\Barobill\EtaxController::class, 'issue'])->name('issue'); Route::post('/send-to-nts', [\App\Http\Controllers\Barobill\EtaxController::class, 'sendToNts'])->name('send-to-nts'); Route::post('/delete', [\App\Http\Controllers\Barobill\EtaxController::class, 'delete'])->name('delete'); + Route::get('/supplier', [\App\Http\Controllers\Barobill\EtaxController::class, 'getSupplier'])->name('supplier'); + Route::post('/supplier', [\App\Http\Controllers\Barobill\EtaxController::class, 'updateSupplier'])->name('supplier.update'); }); // 계좌 입출금내역 (React 페이지) From 01d051e70a7e6216addb17a99cd6dae3f7f40f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 11:24:58 +0900 Subject: [PATCH 44/61] =?UTF-8?q?fix:=EA=B3=B5=EA=B8=89=EC=9E=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=EA=B0=80=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0=20(=EC=95=84=EC=9D=B4=EC=BD=98?= =?UTF-8?q?=E2=86=92=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B2=84=ED=8A=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index fc6c6e04..9b0263d0 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -1194,8 +1194,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s

전자세금계산서 발행 -

{!showIssueForm && ( From 56daab684dcc4f4d6387ed1871b87b441566f805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 11:27:52 +0900 Subject: [PATCH 45/61] =?UTF-8?q?fix:=EA=B3=B5=EA=B8=89=EC=9E=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20requestSubmit=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장 버튼을 form 바깥 footer에서 form 내부로 이동하여 DOM 탐색 없이 직접 submit되도록 변경 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 9b0263d0..c667884a 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -917,15 +917,15 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s handleChange('manager_email', e.target.value)} placeholder="예: tax@company.com" /> +
+ + +
-
- - -
); From 62fe4121d871849a2904e7322705688930d53a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 12:21:02 +0900 Subject: [PATCH 46/61] =?UTF-8?q?refactor:=EC=A0=84=EC=9E=90=EC=84=B8?= =?UTF-8?q?=EA=B8=88=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=E2=86=92=20=EB=B0=94=EB=A1=9C=EB=B9=8C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=80=EB=A1=9C=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EB=B0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4개 분리된 StatCard를 바로빌 참고 화면처럼 컴팩트한 가로 테이블 요약 바로 변경 (발행건수, 총 합계금액, 총 공급가액, 총 세액) Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index c667884a..2c80ce5c 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -117,20 +117,6 @@ return `${year}-${month}-${day}`; }; - // StatCard Component - const StatCard = ({ title, value, subtext, icon }) => ( -
-
-

{title}

-
- {icon} -
-
-
{value}
- {subtext &&
{subtext}
} -
- ); - // IssueForm Component const IssueForm = ({ onIssue, onCancel, supplier }) => { const generateRandomData = () => { @@ -1150,7 +1136,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s 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 ( @@ -1180,12 +1168,29 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s - {/* Dashboard */} -
- } /> - } /> - } /> - } /> + {/* Summary Bar */} +
+
+ + + + + + + + + + + + +
발행건수 + {stats.total.toLocaleString()} + + | + 발행 {stats.issued.toLocaleString()} + / + 전송 {stats.sent.toLocaleString()} + 총 합계금액{stats.totalAmount.toLocaleString()}총 공급가액{stats.totalSupplyAmt.toLocaleString()}총 세액{stats.totalVat.toLocaleString()}
{/* Issue Form Section */} From 23106bbdf44c3d3734ff8ad1c1c316df152fef4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 12:43:03 +0900 Subject: [PATCH 47/61] =?UTF-8?q?feat:=EB=B0=9C=ED=96=89=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EA=B2=80=EC=83=89=EC=A1=B0=EA=B1=B4=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EB=B9=8C=20=EC=8A=A4=ED=83=80=EC=9D=BC=203=ED=96=89?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조회기간: 작성일자/전송일자 선택 + 1주일/1개월/3개월 빠른설정 - 사업자번호/상호 텍스트 검색 필터 추가 - 상태 드롭다운 필터 (전체/작성중/발행완료/전송완료/취소됨) - 정렬 드롭다운 (작성일자/전송일자/공급받는자/합계금액, 오름/내림차순) - 기존 분산된 필터 state를 filters 객체로 통합 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 363 +++++++++--------- 1 file changed, 189 insertions(+), 174 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 2c80ce5c..012254af 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -559,8 +559,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, totalCount }) => { + const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val); const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR'); const getStatusBadge = (status) => { @@ -574,130 +574,158 @@ 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 = '' }) => ( - onSort(column)} - > -
- {children} - -
- - ); + // 스타일 + 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-2 py-1.5 text-sm border border-stone-200 rounded outline-none bg-white focus:ring-2 focus:ring-blue-400"; + 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}건 - )} - -
-
-
-
- - +
+ {/* 검색 조건 패널 */} +
+
+ + + + + + {/* 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' && ( + + )} + +
+
+
+
); }; @@ -976,14 +1004,19 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s const [supplier, setSupplier] = useState(INITIAL_SUPPLIER); const [showSupplierModal, setShowSupplierModal] = useState(false); - // 날짜 필터 상태 (기본: 현재 월) + // 검색 필터 상태 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 [filters, setFilters] = useState({ + dateType: 'supplyDate', + dateFrom: currentMonth.from, + dateTo: currentMonth.to, + bizNo: '', + companyName: '', + status: '', + sortColumn: 'supplyDate', + sortDirection: 'desc', + }); + const updateFilter = (key, value) => setFilters(prev => ({ ...prev, [key]: value })); useEffect(() => { loadInvoices(); @@ -1078,60 +1111,49 @@ 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'); - } - }; - - // 날짜 필터 적용된 송장 목록 + // 필터링 적용 const filteredInvoices = invoices.filter(invoice => { - const supplyDate = invoice.supplyDate; - if (!supplyDate) return true; - return supplyDate >= dateFrom && supplyDate <= dateTo; + // 날짜 필터 + const dateVal = filters.dateType === 'sentAt' ? invoice.sentAt : invoice.supplyDate; + if (dateVal) { + if (dateVal < filters.dateFrom || dateVal > filters.dateTo) return false; + } else if (filters.dateType === 'sentAt') { + return false; // 전송일자 필터인데 전송일 없으면 제외 + } + // 사업자번호 필터 + if (filters.bizNo) { + const q = filters.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 (filters.companyName) { + const q = filters.companyName.toLowerCase(); + const sName = (invoice.supplierName || '').toLowerCase(); + const rName = (invoice.recipientName || '').toLowerCase(); + if (!sName.includes(q) && !rName.includes(q)) return false; + } + // 상태 필터 + if (filters.status && invoice.status !== filters.status) return false; + return true; }); // 정렬 적용 const sortedInvoices = [...filteredInvoices].sort((a, b) => { - let aVal = a[sortColumn]; - let bVal = b[sortColumn]; - - // null/undefined 처리 + let aVal = a[filters.sortColumn]; + let bVal = b[filters.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 filters.sortDirection === 'asc' ? cmp : -cmp; } - - // 숫자 비교 - if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; - if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; + if (aVal < bVal) return filters.sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return filters.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, @@ -1220,16 +1242,9 @@ 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} totalCount={invoices.length} - sortColumn={sortColumn} - sortDirection={sortDirection} - onSort={handleSort} /> {/* API Logs */} From eed14d88496914a61b346528dfc98546567c12a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 12:49:29 +0900 Subject: [PATCH 48/61] =?UTF-8?q?feat:=EB=B0=9C=ED=96=89=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EA=B2=80=EC=83=89=20=EC=A1=B0=ED=9A=8C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80=20(=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EA=B2=80=EC=83=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filters(입력용) / appliedFilters(검색결과용) 분리 - 검색 조건 변경은 즉시 반영되지 않고 조회 버튼 클릭 시 적용 - 검색 패널 오른쪽에 파란색 조회 버튼 (rowSpan 3행) 배치 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 012254af..fe6553e2 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -559,7 +559,7 @@ }; // InvoiceList Component - const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, filters, updateFilter, totalCount }) => { + 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'); @@ -603,15 +603,11 @@
{/* 검색 조건 패널 */}
- - - - - +
{/* Row 1: 조회기간 */} - + {/* Row 2: 사업자등록번호 / 상호 */} @@ -641,7 +642,7 @@ - {/* Row 3: 상태 / 정렬 */} + {/* Row 3: 상태 / 정렬 / 조회건수 */} - From 386558890b19c9bd8e273fc2b514e5ba27706881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 12:53:40 +0900 Subject: [PATCH 50/61] =?UTF-8?q?fix:=EA=B2=80=EC=83=89=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20select=20=EB=84=88=EB=B9=84=20=EC=97=AC=EC=9C=A0=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4=20(pr-8=20+=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=99=94=EC=82=B4=ED=91=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 텍스트와 꺽음 마크 겹침 방지를 위해 오른쪽 패딩 확대 및 appearance-none + 커스텀 SVG 화살표 적용 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 7ee5f7b4..86ad45a8 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -596,7 +596,7 @@ 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-2 py-1.5 text-sm border border-stone-200 rounded outline-none bg-white focus:ring-2 focus:ring-blue-400"; + 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 ( From be28b768722a9495a5f10732116303bfb0545b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 13:13:17 +0900 Subject: [PATCH 51/61] =?UTF-8?q?fix:=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=84=A0=ED=83=9D=20=EA=B0=9C=EC=84=A0=20-?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EB=B3=B4=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?,=20HQ=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- app/Http/Controllers/TenantController.php | 6 +++- app/Providers/ViewServiceProvider.php | 8 +++++ resources/views/partials/header.blade.php | 37 +++++----------------- resources/views/partials/sidebar.blade.php | 6 ---- 4 files changed, 21 insertions(+), 36 deletions(-) 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/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/resources/views/partials/header.blade.php b/resources/views/partials/header.blade.php index a17c6a8c..1eff6955 100644 --- a/resources/views/partials/header.blade.php +++ b/resources/views/partials/header.blade.php @@ -17,28 +17,17 @@ class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transi
{{ config('app.name') }} - @if(session('selected_tenant_id')) - @php - $mobileTenant = $globalTenants->firstWhere('id', session('selected_tenant_id')); - @endphp - @if($mobileTenant) - - @endif - @else + @php + $mobileTenant = $globalTenants->firstWhere('id', session('selected_tenant_id')); + @endphp + @if($mobileTenant) @endif
@@ -59,12 +48,6 @@ class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray- onchange="document.getElementById('tenant-switch-form').submit()" class="border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primary min-w-[200px]" > - - @if($globalTenants->isNotEmpty()) - - @endif @foreach($globalTenants as $tenant) - @if($globalTenants->isNotEmpty()) - - @endif @foreach($globalTenants as $tenant)
- + @@ -149,7 +149,7 @@ - 납입대기 → + 입금대기 → 개발비 입금 → 지급예정 → 익월 10일 → diff --git a/resources/views/sales/dashboard/partials/stats.blade.php b/resources/views/sales/dashboard/partials/stats.blade.php index ef2d8e01..0ff1cdf9 100644 --- a/resources/views/sales/dashboard/partials/stats.blade.php +++ b/resources/views/sales/dashboard/partials/stats.blade.php @@ -95,7 +95,7 @@ class="bg-emerald-50 border border-emerald-200 rounded-lg p-3 text-left hover:bg - + @@ -149,7 +149,7 @@ class="bg-emerald-50 border border-emerald-200 rounded-lg p-3 text-left hover:bg - 납입대기 → + 입금대기 → 개발비 입금 → 지급예정 → 익월 10일 → From 6bac811d960623e7ca2d7f1dcc78cae68fc7ecca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 16:11:14 +0900 Subject: [PATCH 53/61] =?UTF-8?q?docs:=EC=98=81=EC=97=85=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=84=88=EA=B0=80=EC=9D=B4=EB=93=9C=EB=B6=81=20=EB=82=A9?= =?UTF-8?q?=EC=9E=85=EB=8C=80=EA=B8=B0=E2=86=92=EC=9E=85=EA=B8=88=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=9A=A9=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- resources/markdown/영업파트너가이드북.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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차 수당: 입금대기 / 지급예정 / 지급완료 금액 - 진행률 바로 지급 현황 시각화 --- From 1519673b34100ef7f34278c160a0310ed195ac9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 16:24:00 +0900 Subject: [PATCH 54/61] =?UTF-8?q?feat:=EC=98=81=EC=97=85=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=84=88=20=EA=B3=A0=EA=B0=9D=EA=B4=80=EB=A6=AC=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=99=84=EB=A3=8C=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=EC=9E=85=EB=A0=A5=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 진행완료(두 시나리오 모두 100%) 필터 버튼 추가 (보라색) - 진행률 미달 시 수당 날짜 input 5개 disabled 처리 - 통계에 progress_complete 건수 추가 Co-Authored-By: Claude Opus 4.5 --- .../Sales/AdminProspectController.php | 103 ++++++++++++++---- .../partials/content.blade.php | 23 +++- 2 files changed, 98 insertions(+), 28 deletions(-) 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/resources/views/sales/admin-prospects/partials/content.blade.php b/resources/views/sales/admin-prospects/partials/content.blade.php index 96fec4db..c3514480 100644 --- a/resources/views/sales/admin-prospects/partials/content.blade.php +++ b/resources/views/sales/admin-prospects/partials/content.blade.php @@ -31,6 +31,10 @@ class="px-3 py-2 rounded-lg text-sm font-medium transition {{ !request('status') class="px-3 py-2 rounded-lg text-sm font-medium transition {{ request('status') === 'active' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}"> 진행중 + + 진행완료 + 계약완료 @@ -132,49 +136,58 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none foc {{ $prospect->manager_progress }}% + @php + $commissionDisabled = $prospect->sales_progress < 100 || $prospect->manager_progress < 100; + $disabledClass = $commissionDisabled ? 'opacity-40 cursor-not-allowed bg-gray-100' : ''; + @endphp {{-- 1차 납입완료 --}} {{-- 1차 파트너 수당지급 --}} {{-- 2차 납입완료 --}} {{-- 2차 파트너 수당지급 --}} {{-- 매니저 수당지급 --}}
+ *조회기간 @@ -628,6 +624,11 @@ + +
조건 @@ -1004,9 +1005,9 @@ const [supplier, setSupplier] = useState(INITIAL_SUPPLIER); const [showSupplierModal, setShowSupplierModal] = useState(false); - // 검색 필터 상태 + // 검색 필터 상태: filters = 입력용, appliedFilters = 실제 필터링용 const currentMonth = getMonthDates(0); - const [filters, setFilters] = useState({ + const defaultFilters = { dateType: 'supplyDate', dateFrom: currentMonth.from, dateTo: currentMonth.to, @@ -1015,8 +1016,11 @@ 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(); @@ -1111,46 +1115,43 @@ ); } - // 필터링 적용 + // 필터링 적용 (appliedFilters 기준 - 조회 버튼 클릭 시에만 갱신) + const af = appliedFilters; const filteredInvoices = invoices.filter(invoice => { - // 날짜 필터 - const dateVal = filters.dateType === 'sentAt' ? invoice.sentAt : invoice.supplyDate; + const dateVal = af.dateType === 'sentAt' ? invoice.sentAt : invoice.supplyDate; if (dateVal) { - if (dateVal < filters.dateFrom || dateVal > filters.dateTo) return false; - } else if (filters.dateType === 'sentAt') { - return false; // 전송일자 필터인데 전송일 없으면 제외 + if (dateVal < af.dateFrom || dateVal > af.dateTo) return false; + } else if (af.dateType === 'sentAt') { + return false; } - // 사업자번호 필터 - if (filters.bizNo) { - const q = filters.bizNo.replace(/-/g, ''); + 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 (filters.companyName) { - const q = filters.companyName.toLowerCase(); + 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 (filters.status && invoice.status !== filters.status) return false; + if (af.status && invoice.status !== af.status) return false; return true; }); // 정렬 적용 const sortedInvoices = [...filteredInvoices].sort((a, b) => { - let aVal = a[filters.sortColumn]; - let bVal = b[filters.sortColumn]; + 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 cmp = aVal.localeCompare(bVal, 'ko-KR'); - return filters.sortDirection === 'asc' ? cmp : -cmp; + return af.sortDirection === 'asc' ? cmp : -cmp; } - if (aVal < bVal) return filters.sortDirection === 'asc' ? -1 : 1; - if (aVal > bVal) return filters.sortDirection === 'asc' ? 1 : -1; + if (aVal < bVal) return af.sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return af.sortDirection === 'asc' ? 1 : -1; return 0; }); @@ -1244,6 +1245,7 @@ onDelete={handleDelete} filters={filters} updateFilter={updateFilter} + onSearch={handleSearch} totalCount={invoices.length} /> From a6d92b533c6c62ebb7a12fc84fb9ffc2d6125d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 12:51:39 +0900 Subject: [PATCH 49/61] =?UTF-8?q?fix:=EC=A1=B0=ED=9A=8C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=97=B4=20=EB=84=88=EB=B9=84=20=ED=99=95=EB=8C=80?= =?UTF-8?q?=20(70px=20=E2=86=92=20100px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/etax/index.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index fe6553e2..7ee5f7b4 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -624,8 +624,8 @@ - +
구분 총액납입대기입금대기 지급예정 지급완료
구분 총액납입대기입금대기 지급예정 지급완료
From 90e8b3ea761f8c0b14938097e5b261e51191ec2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 18:02:20 +0900 Subject: [PATCH 55/61] =?UTF-8?q?feat:=EC=A0=84=EC=9E=90=EC=84=B8=EA=B8=88?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=84=9C=20=ED=92=88=EB=AA=A9=EB=B3=84=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=9D=BC=EC=9E=90(=EC=9B=94/=EC=9D=BC)=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품목 데이터 구조에 month, day 필드 추가 - 품목 테이블에 월/일 입력 컬럼 추가 - 바로빌 API PurchaseExpiry 필드에 YYYYMMDD 형식 거래일자 전달 - 상세 모달에 월/일 표시 추가 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Barobill/EtaxController.php | 10 +++++- resources/views/barobill/etax/index.blade.php | 36 +++++++++++++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Barobill/EtaxController.php b/app/Http/Controllers/Barobill/EtaxController.php index 29aadccc..976f9b2e 100644 --- a/app/Http/Controllers/Barobill/EtaxController.php +++ b/app/Http/Controllers/Barobill/EtaxController.php @@ -577,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/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 86ad45a8..8d6a0a68 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -127,19 +127,22 @@ // 테스트 모드: 랜덤 품목 데이터 생성 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; - items.push({ name: itemName, qty, unitPrice, vatType: 'vat' }); + const randomDay = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0'); + items.push({ name: itemName, qty, unitPrice, vatType: 'vat', month: testMonth, day: randomDay }); } - const randomDaysAgo = Math.floor(Math.random() * 30); - supplyDate = new Date(); - supplyDate.setDate(supplyDate.getDate() - randomDaysAgo); } else { // 운영 모드: 빈 품목 1건 - items = [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat' }]; supplyDate = new Date(); + const defaultMonth = String(supplyDate.getMonth() + 1).padStart(2, '0'); + items = [{ name: '', qty: 1, unitPrice: 0, vatType: 'vat', month: defaultMonth, day: '' }]; } // 공급받는자: 테스트 모드에서만 샘플 데이터, 운영 모드에서는 비움 @@ -191,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) => { @@ -419,7 +423,9 @@
- + + + @@ -430,6 +436,8 @@ + + @@ -453,6 +461,12 @@ return ( + + @@ -491,7 +505,7 @@ - + @@ -778,6 +792,7 @@
품목명 수량 단가
+ { 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()}
+ @@ -788,6 +803,7 @@ {invoice.items?.map((item, index) => ( + @@ -798,12 +814,12 @@ - + - + From b3f48e0f6004475d427c320481f139b2fb3d3a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 19:11:47 +0900 Subject: [PATCH 56/61] =?UTF-8?q?fix:=EC=B9=B4=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=82=B4=EC=97=AD=20=EB=82=B4=EC=97=AD=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=84=88=EB=B9=84=20=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/ecard/index.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index e3e048fe..046ce500 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 - + From 60915f319c798e5afeafdb3728560ee3e1a01f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 19:22:37 +0900 Subject: [PATCH 57/61] =?UTF-8?q?feat:=EC=B9=B4=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=82=B4=EC=97=AD=20=EA=B3=B5=EC=A0=9C/=EB=B6=88?= =?UTF-8?q?=EA=B3=B5=EC=A0=9C=20=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 총 사용금액 유지 (승인/취소 건수 subtext로 통합) - 공제 금액/건수 카드 추가 - 불공제 금액/건수 카드 추가 - 부가세 합계 카드 추가 - 5컬럼 그리드로 변경 Co-Authored-By: Claude Opus 4.5 --- .../views/barobill/ecard/index.blade.php | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index 046ce500..2ed0839d 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -1244,6 +1244,20 @@ className="text-xs text-amber-600 hover:text-amber-700 underline" const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원'; + // 공제/불공 통계 계산 + const deductionStats = logs.reduce((acc, log) => { + const type = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible'); + const amount = Math.abs(log.approvalAmount || 0); + if (type === 'deductible') { + acc.deductibleAmount += amount; + acc.deductibleCount += 1; + } else { + acc.nonDeductibleAmount += amount; + acc.nonDeductibleCount += 1; + } + return acc; + }, { deductibleAmount: 0, deductibleCount: 0, nonDeductibleAmount: 0, nonDeductibleCount: 0 }); + return (
{/* Page Header */} @@ -1272,28 +1286,35 @@ className="text-xs text-amber-600 hover:text-amber-700 underline"
{/* Dashboard */} -
+
} color="purple" /> } color="green" /> } + title="불공제" + value={formatCurrency(deductionStats.nonDeductibleAmount)} + subtext={`${deductionStats.nonDeductibleCount.toLocaleString()}건`} + icon={} color="red" /> + sum + Math.abs(log.tax || 0), 0))} + subtext="조회기간 합계" + icon={} + color="stone" + /> Date: Wed, 4 Feb 2026 19:27:36 +0900 Subject: [PATCH 58/61] =?UTF-8?q?fix:=EC=B9=B4=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=82=B4=EC=97=AD=20=EA=B3=B5=EC=A0=9C/=EB=B6=88?= =?UTF-8?q?=EA=B3=B5=EC=A0=9C=20=ED=86=B5=EA=B3=84=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드에서 페이지네이션 전 전체 데이터로 공제/불공제/부가세 통계 산출 - parseTransactionLogs에 deductibleAmount/Count, nonDeductibleAmount/Count, totalTax 추가 - getAllCardsTransactions summary에 공제/불공제 통계 포함 - 프론트엔드에서 logs 기반 계산 제거, summary 데이터 사용 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Barobill/EcardController.php | 51 ++++++++++++++++++- .../views/barobill/ecard/index.blade.php | 24 ++------- 2 files changed, 54 insertions(+), 21 deletions(-) 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/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index 2ed0839d..8af81bb9 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -1244,20 +1244,6 @@ className="text-xs text-amber-600 hover:text-amber-700 underline" const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원'; - // 공제/불공 통계 계산 - const deductionStats = logs.reduce((acc, log) => { - const type = log.deductionType || (log.merchantBizNum ? 'deductible' : 'non_deductible'); - const amount = Math.abs(log.approvalAmount || 0); - if (type === 'deductible') { - acc.deductibleAmount += amount; - acc.deductibleCount += 1; - } else { - acc.nonDeductibleAmount += amount; - acc.nonDeductibleCount += 1; - } - return acc; - }, { deductibleAmount: 0, deductibleCount: 0, nonDeductibleAmount: 0, nonDeductibleCount: 0 }); - return (
{/* Page Header */} @@ -1296,21 +1282,21 @@ className="text-xs text-amber-600 hover:text-amber-700 underline" /> } color="green" /> } color="red" /> sum + Math.abs(log.tax || 0), 0))} + value={formatCurrency(summary.totalTax)} subtext="조회기간 합계" icon={} color="stone" From f2d5b250be3e600dac32bbca8d182c55e42ca60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 19:29:39 +0900 Subject: [PATCH 59/61] =?UTF-8?q?fix:=EC=B9=B4=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=82=B4=EC=97=AD=20=EB=82=A0=EC=A7=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dateFrom/dateTo/selectedCard 변경 시 자동 loadTransactions useEffect 제거 - 조회 버튼 클릭 시에만 조회 동작 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/ecard/index.blade.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index 8af81bb9..2c2a92a6 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -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); From ef466b2165e893d9ad5294f5e4d1f3491d74ab8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 19:41:07 +0900 Subject: [PATCH 60/61] =?UTF-8?q?fix:=EA=B3=84=EC=A2=8C=20=EC=9E=85?= =?UTF-8?q?=EC=B6=9C=EA=B8=88=EB=82=B4=EC=97=AD=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dateFrom/dateTo/selectedAccount 변경 시 자동 loadTransactions useEffect 제거 - 조회 버튼 클릭 시에만 조회 동작 Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/eaccount/index.blade.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 1f123450..4ade8431 100644 --- a/resources/views/barobill/eaccount/index.blade.php +++ b/resources/views/barobill/eaccount/index.blade.php @@ -817,15 +817,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); From 528899ec9e9835b989833e337bbb04981fe00b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 19:45:24 +0900 Subject: [PATCH 61/61] =?UTF-8?q?feat:=EA=B3=84=EC=A2=8C=20=EC=9E=85?= =?UTF-8?q?=EC=B6=9C=EA=B8=88=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- resources/views/barobill/eaccount/index.blade.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 4ade8431..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}건 @@ -1050,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}
월/일 품목명 수량 단가
{item.month && item.day ? `${item.month}/${item.day}` : '-'} {item.name} {item.qty} {formatCurrency(item.unitPrice)}
합계합계 {formatCurrency(invoice.totalSupplyAmt)} {formatCurrency(invoice.totalVat)}
총 합계총 합계 {formatCurrency(invoice.total)}
카드정보 공제 증빙/판매자상호내역내역 금액 부가세 승인번호