diff --git a/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php b/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php index 7afc0caa..39d103b7 100644 --- a/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php +++ b/app/Http/Controllers/Api/Admin/Barobill/BarobillMemberController.php @@ -24,6 +24,8 @@ public function __construct( /** * 회원사 조회 및 비밀번호 검증 헬퍼 * + * 회원사의 서버 모드에 따라 BarobillService의 서버 모드도 자동 전환합니다. + * * @return BarobillMember|JsonResponse 회원사 객체 또는 에러 응답 */ private function validateMemberForUrlApi(int $id): BarobillMember|JsonResponse @@ -45,6 +47,9 @@ private function validateMemberForUrlApi(int $id): BarobillMember|JsonResponse ], 422); } + // 회원사의 서버 모드로 BarobillService 전환 + $this->barobillService->setServerMode($member->server_mode ?? 'test'); + return $member; } @@ -856,4 +861,91 @@ public function getServiceCodes(): JsonResponse ], ]); } + + /** + * 회원사별 서버 모드 변경 + * + * 특정 회원사의 바로빌 서버 모드(테스트/운영)를 변경합니다. + * 주의: 운영 서버로 전환 시 요금이 부과됩니다. + */ + public function updateServerMode(Request $request, int $id): JsonResponse + { + $member = BarobillMember::find($id); + + if (!$member) { + return response()->json([ + 'success' => false, + 'message' => '회원사를 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'server_mode' => 'required|in:test,production', + 'confirmed' => 'required|boolean', + ], [ + 'server_mode.required' => '서버 모드를 선택해주세요.', + 'server_mode.in' => '서버 모드는 test 또는 production 이어야 합니다.', + 'confirmed.required' => '경고 확인이 필요합니다.', + ]); + + // 확인 체크 + if (!$validated['confirmed']) { + return response()->json([ + 'success' => false, + 'message' => '서버 변경 경고를 확인해주세요.', + ], 422); + } + + $oldMode = $member->server_mode ?? 'test'; + $newMode = $validated['server_mode']; + + // 변경 없으면 바로 반환 + if ($oldMode === $newMode) { + return response()->json([ + 'success' => true, + 'message' => '서버 모드가 이미 ' . ($newMode === 'test' ? '테스트' : '운영') . ' 서버입니다.', + 'data' => $member, + ]); + } + + $member->update(['server_mode' => $newMode]); + + Log::info('바로빌 회원사 서버 모드 변경', [ + 'member_id' => $id, + 'biz_no' => $member->biz_no, + 'corp_name' => $member->corp_name, + 'old_mode' => $oldMode, + 'new_mode' => $newMode, + 'user_id' => auth()->id(), + ]); + + return response()->json([ + 'success' => true, + 'message' => ($newMode === 'test' ? '테스트' : '운영') . ' 서버로 변경되었습니다.', + 'data' => $member->fresh(), + ]); + } + + /** + * 회원사별 서버 모드 조회 + */ + public function getServerMode(int $id): JsonResponse + { + $member = BarobillMember::find($id); + + if (!$member) { + return response()->json([ + 'success' => false, + 'message' => '회원사를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'server_mode' => $member->server_mode ?? 'test', + 'server_mode_label' => $member->server_mode_label, + ], + ]); + } } diff --git a/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php b/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php index 6b582fca..a33cbe0e 100644 --- a/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php +++ b/app/Http/Controllers/Api/Admin/Barobill/BarobillSettingController.php @@ -102,6 +102,53 @@ public function store(Request $request): JsonResponse } } + /** + * 서비스 설정 개별 저장 (체크박스 변경 시 즉시 저장) + */ + public function updateService(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + + $validated = $request->validate([ + 'field' => 'required|in:use_tax_invoice,use_bank_account,use_card_usage,use_hometax', + 'value' => 'required|boolean', + ]); + + try { + $setting = BarobillSetting::updateOrCreate( + ['tenant_id' => $tenantId], + [ + $validated['field'] => $validated['value'], + 'corp_name' => '미설정', + 'ceo_name' => '미설정', + 'corp_num' => '0000000000', + ] + ); + + $serviceNames = [ + 'use_tax_invoice' => '전자세금계산서', + 'use_bank_account' => '계좌조회', + 'use_card_usage' => '카드사용내역', + 'use_hometax' => '홈텍스매입/매출', + ]; + + $serviceName = $serviceNames[$validated['field']] ?? $validated['field']; + $status = $validated['value'] ? '활성화' : '비활성화'; + + return response()->json([ + 'success' => true, + 'message' => "{$serviceName} 서비스가 {$status}되었습니다.", + ]); + } catch (\Exception $e) { + Log::error('바로빌 서비스 설정 저장 실패', ['error' => $e->getMessage()]); + + return response()->json([ + 'success' => false, + 'message' => '설정 저장에 실패했습니다.', + ], 500); + } + } + /** * 서비스 이용 여부 확인 (다른 메뉴에서 참조용) */ diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 3f011a89..1edc6e93 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -107,16 +107,58 @@ public function index(Request $request): View|Response // 해당 테넌트의 바로빌 회원사 정보 $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + // 테넌트별 서버 모드 적용 (회원사 설정 우선) + $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode; + + // 서버 모드에 따라 SOAP 설정 재초기화 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + return view('barobill.eaccount.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, - 'isTestMode' => $this->isTestMode, + 'isTestMode' => $isTestMode, 'hasSoapClient' => $this->soapClient !== null, 'currentTenant' => $currentTenant, 'barobillMember' => $barobillMember, ]); } + /** + * 회원사 서버 모드에 따라 SOAP 설정 적용 + */ + private function applyMemberServerMode(BarobillMember $member): void + { + $memberTestMode = $member->isTestMode(); + $targetEnv = $memberTestMode ? 'test' : 'production'; + + // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용) + $config = BarobillConfig::where('environment', $targetEnv)->first(); + + if ($config) { + $this->isTestMode = $memberTestMode; + $this->certKey = $config->cert_key; + $this->corpNum = $config->corp_num; + $baseUrl = $config->base_url ?: ($memberTestMode + ? 'https://testws.baroservice.com' + : 'https://ws.baroservice.com'); + $this->soapUrl = $baseUrl . '/BANKACCOUNT.asmx?WSDL'; + + // SOAP 클라이언트 재초기화 + $this->initSoapClient(); + + Log::info('[Eaccount] 서버 모드 적용', [ + 'targetEnv' => $targetEnv, + 'certKey' => substr($this->certKey ?? '', 0, 10) . '...', + 'corpNum' => $this->corpNum, + 'soapUrl' => $this->soapUrl, + ]); + } else { + Log::warning('[Eaccount] BarobillConfig 없음', ['targetEnv' => $targetEnv]); + } + } + /** * 등록된 계좌 목록 조회 (GetBankAccountEx) */ @@ -129,6 +171,11 @@ public function accounts(Request $request): JsonResponse $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + // 테넌트별 서버 모드 적용 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + // 바로빌 사용자 ID 결정 $userId = $barobillMember?->barobill_id ?? ''; @@ -210,6 +257,11 @@ public function transactions(Request $request): JsonResponse $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); $userId = $barobillMember?->barobill_id ?? ''; + // 테넌트별 서버 모드 적용 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + // DB에서 저장된 계정과목 데이터 조회 $savedData = BankTransaction::getByDateRange($tenantId, $startDate, $endDate, $bankAccountNum ?: null); @@ -532,12 +584,11 @@ private function getBankName(string $code): string } /** - * 계정과목 목록 조회 + * 계정과목 목록 조회 (글로벌 데이터) */ public function accountCodes(): JsonResponse { - $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); - $codes = AccountCode::getActiveByTenant($tenantId); + $codes = AccountCode::getActive(); return response()->json([ 'success' => true, @@ -551,15 +602,11 @@ public function accountCodes(): JsonResponse } /** - * 전체 계정과목 목록 조회 (설정용, 비활성 포함) + * 전체 계정과목 목록 조회 (설정용, 비활성 포함, 글로벌 데이터) */ public function accountCodesAll(): JsonResponse { - $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); - $codes = AccountCode::where('tenant_id', $tenantId) - ->orderBy('sort_order') - ->orderBy('code') - ->get(); + $codes = AccountCode::getAll(); return response()->json([ 'success' => true, @@ -568,23 +615,19 @@ public function accountCodesAll(): JsonResponse } /** - * 계정과목 추가 + * 계정과목 추가 (글로벌 데이터) */ public function accountCodeStore(Request $request): JsonResponse { try { - $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); - $validated = $request->validate([ 'code' => 'required|string|max:10', 'name' => 'required|string|max:100', 'category' => 'nullable|string|max:50', ]); - // 중복 체크 - $exists = AccountCode::where('tenant_id', $tenantId) - ->where('code', $validated['code']) - ->exists(); + // 중복 체크 (글로벌) + $exists = AccountCode::where('code', $validated['code'])->exists(); if ($exists) { return response()->json([ @@ -593,10 +636,10 @@ public function accountCodeStore(Request $request): JsonResponse ], 422); } - $maxSort = AccountCode::where('tenant_id', $tenantId)->max('sort_order') ?? 0; + $maxSort = AccountCode::max('sort_order') ?? 0; $accountCode = AccountCode::create([ - 'tenant_id' => $tenantId, + 'tenant_id' => self::HEADQUARTERS_TENANT_ID, // 글로벌 데이터는 기본 테넌트에 저장 'code' => $validated['code'], 'name' => $validated['name'], 'category' => $validated['category'] ?? null, @@ -618,16 +661,12 @@ public function accountCodeStore(Request $request): JsonResponse } /** - * 계정과목 수정 + * 계정과목 수정 (글로벌 데이터) */ public function accountCodeUpdate(Request $request, int $id): JsonResponse { try { - $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); - - $accountCode = AccountCode::where('tenant_id', $tenantId) - ->where('id', $id) - ->first(); + $accountCode = AccountCode::find($id); if (!$accountCode) { return response()->json([ @@ -643,10 +682,9 @@ public function accountCodeUpdate(Request $request, int $id): JsonResponse 'is_active' => 'sometimes|boolean', ]); - // 코드 변경 시 중복 체크 + // 코드 변경 시 중복 체크 (글로벌) if (isset($validated['code']) && $validated['code'] !== $accountCode->code) { - $exists = AccountCode::where('tenant_id', $tenantId) - ->where('code', $validated['code']) + $exists = AccountCode::where('code', $validated['code']) ->where('id', '!=', $id) ->exists(); @@ -674,16 +712,12 @@ public function accountCodeUpdate(Request $request, int $id): JsonResponse } /** - * 계정과목 삭제 + * 계정과목 삭제 (글로벌 데이터) */ public function accountCodeDestroy(int $id): JsonResponse { try { - $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); - - $accountCode = AccountCode::where('tenant_id', $tenantId) - ->where('id', $id) - ->first(); + $accountCode = AccountCode::find($id); if (!$accountCode) { return response()->json([ diff --git a/app/Http/Controllers/Barobill/EcardController.php b/app/Http/Controllers/Barobill/EcardController.php index 5794627c..e0c471e6 100644 --- a/app/Http/Controllers/Barobill/EcardController.php +++ b/app/Http/Controllers/Barobill/EcardController.php @@ -107,16 +107,58 @@ public function index(Request $request): View|Response // 해당 테넌트의 바로빌 회원사 정보 $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + // 테넌트별 서버 모드 적용 (회원사 설정 우선) + $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode; + + // 서버 모드에 따라 SOAP 설정 재초기화 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + return view('barobill.ecard.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, - 'isTestMode' => $this->isTestMode, + 'isTestMode' => $isTestMode, 'hasSoapClient' => $this->soapClient !== null, 'currentTenant' => $currentTenant, 'barobillMember' => $barobillMember, ]); } + /** + * 회원사 서버 모드에 따라 SOAP 설정 적용 + */ + private function applyMemberServerMode(BarobillMember $member): void + { + $memberTestMode = $member->isTestMode(); + $targetEnv = $memberTestMode ? 'test' : 'production'; + + // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용) + $config = BarobillConfig::where('environment', $targetEnv)->first(); + + if ($config) { + $this->isTestMode = $memberTestMode; + $this->certKey = $config->cert_key; + $this->corpNum = $config->corp_num; + $baseUrl = $config->base_url ?: ($memberTestMode + ? 'https://testws.baroservice.com' + : 'https://ws.baroservice.com'); + $this->soapUrl = $baseUrl . '/CARD.asmx?WSDL'; + + // SOAP 클라이언트 재초기화 + $this->initSoapClient(); + + Log::info('[Ecard] 서버 모드 적용', [ + 'targetEnv' => $targetEnv, + 'certKey' => substr($this->certKey ?? '', 0, 10) . '...', + 'corpNum' => $this->corpNum, + 'soapUrl' => $this->soapUrl, + ]); + } else { + Log::warning('[Ecard] BarobillConfig 없음', ['targetEnv' => $targetEnv]); + } + } + /** * 등록된 카드 목록 조회 (GetCardEx2) * 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2 @@ -124,6 +166,13 @@ public function index(Request $request): View|Response public function cards(Request $request): JsonResponse { try { + // 테넌트별 서버 모드 적용 + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + $availOnly = $request->input('availOnly', 0); $result = $this->callSoap('GetCardEx2', [ @@ -220,6 +269,11 @@ public function transactions(Request $request): JsonResponse $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); $userId = $barobillMember?->barobill_id ?? ''; + // 테넌트별 서버 모드 적용 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + // 디버그 로그 Log::info('[ECard] 조회 요청', [ 'tenantId' => $tenantId, diff --git a/app/Http/Controllers/Barobill/EtaxController.php b/app/Http/Controllers/Barobill/EtaxController.php index 3befd6cf..8fc60f55 100644 --- a/app/Http/Controllers/Barobill/EtaxController.php +++ b/app/Http/Controllers/Barobill/EtaxController.php @@ -99,16 +99,58 @@ public function index(Request $request): View|Response ? BarobillMember::where('tenant_id', $tenantId)->first() : null; + // 테넌트별 서버 모드 적용 (회원사 설정 우선) + $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode; + + // 서버 모드에 따라 SOAP 설정 재초기화 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + return view('barobill.etax.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, - 'isTestMode' => $this->isTestMode, + 'isTestMode' => $isTestMode, 'hasSoapClient' => $this->soapClient !== null, 'currentTenant' => $currentTenant, 'barobillMember' => $barobillMember, ]); } + /** + * 회원사 서버 모드에 따라 SOAP 설정 적용 + */ + private function applyMemberServerMode(BarobillMember $member): void + { + $memberTestMode = $member->isTestMode(); + $targetEnv = $memberTestMode ? 'test' : 'production'; + + // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용) + $config = BarobillConfig::where('environment', $targetEnv)->first(); + + if ($config) { + $this->isTestMode = $memberTestMode; + $this->certKey = $config->cert_key; + $this->corpNum = $config->corp_num; + $baseUrl = $config->base_url ?: ($memberTestMode + ? 'https://testws.baroservice.com' + : 'https://ws.baroservice.com'); + $this->soapUrl = $baseUrl . '/TI.asmx?WSDL'; + + // SOAP 클라이언트 재초기화 + $this->initSoapClient(); + + Log::info('[Etax] 서버 모드 적용', [ + 'targetEnv' => $targetEnv, + 'certKey' => substr($this->certKey ?? '', 0, 10) . '...', + 'corpNum' => $this->corpNum, + 'soapUrl' => $this->soapUrl, + ]); + } else { + Log::warning('[Etax] BarobillConfig 없음', ['targetEnv' => $targetEnv]); + } + } + // 바로빌 파트너사 (본사) 테넌트 ID private const HEADQUARTERS_TENANT_ID = 1; @@ -153,6 +195,13 @@ public function getInvoices(): JsonResponse */ public function issue(Request $request): JsonResponse { + // 테넌트별 서버 모드 적용 + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + $input = $request->all(); $useRealAPI = $this->soapClient !== null && ($this->isTestMode || !empty($this->certKey)); @@ -221,6 +270,13 @@ public function issue(Request $request): JsonResponse */ public function sendToNts(Request $request): JsonResponse { + // 테넌트별 서버 모드 적용 + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + $invoiceId = $request->input('invoiceId'); // 인보이스 조회 diff --git a/app/Http/Controllers/Barobill/HometaxController.php b/app/Http/Controllers/Barobill/HometaxController.php index 55b58051..53f12823 100644 --- a/app/Http/Controllers/Barobill/HometaxController.php +++ b/app/Http/Controllers/Barobill/HometaxController.php @@ -107,10 +107,18 @@ public function index(Request $request): View|Response // 해당 테넌트의 바로빌 회원사 정보 $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + // 테넌트별 서버 모드 적용 (회원사 설정 우선) + $isTestMode = $barobillMember ? $barobillMember->isTestMode() : $this->isTestMode; + + // 서버 모드에 따라 SOAP 설정 재초기화 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + return view('barobill.hometax.index', [ 'certKey' => $this->certKey, 'corpNum' => $this->corpNum, - 'isTestMode' => $this->isTestMode, + 'isTestMode' => $isTestMode, 'hasSoapClient' => $this->soapClient !== null, 'tenantId' => $tenantId, 'currentTenant' => $currentTenant, @@ -118,6 +126,39 @@ public function index(Request $request): View|Response ]); } + /** + * 회원사 서버 모드에 따라 SOAP 설정 적용 + */ + private function applyMemberServerMode(BarobillMember $member): void + { + $memberTestMode = $member->isTestMode(); + $targetEnv = $memberTestMode ? 'test' : 'production'; + + // 해당 환경의 BarobillConfig 조회 (is_active 무관하게 환경에 맞는 설정 사용) + $config = BarobillConfig::where('environment', $targetEnv)->first(); + + if ($config) { + $this->isTestMode = $memberTestMode; + $this->certKey = $config->cert_key; + $this->corpNum = $config->corp_num; + $this->baseUrl = $config->base_url ?: ($memberTestMode + ? 'https://testws.baroservice.com' + : 'https://ws.baroservice.com'); + + // SOAP 클라이언트 재초기화 + $this->initSoapClient(); + + Log::info('[Hometax] 서버 모드 적용', [ + 'targetEnv' => $targetEnv, + 'certKey' => substr($this->certKey ?? '', 0, 10) . '...', + 'corpNum' => $this->corpNum, + 'baseUrl' => $this->baseUrl, + ]); + } else { + Log::warning('[Hometax] BarobillConfig 없음', ['targetEnv' => $targetEnv]); + } + } + /** * 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList) * @@ -146,6 +187,11 @@ public function sales(Request $request): JsonResponse ]); } + // 테넌트별 서버 모드 적용 + if ($barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + $userId = $barobillMember->barobill_id ?? ''; if (empty($userId)) { @@ -221,12 +267,16 @@ public function sales(Request $request): JsonResponse // 작성일 기준으로 정렬 (최신순) usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); + // 마지막 매출 수집 시간 업데이트 + $barobillMember->update(['last_sales_fetch_at' => now()]); + return response()->json([ 'success' => true, 'data' => [ 'invoices' => $allInvoices, 'pagination' => $lastPagination, - 'summary' => $totalSummary + 'summary' => $totalSummary, + 'lastFetchAt' => now()->format('Y-m-d H:i:s') ] ]); } catch (\Throwable $e) { @@ -266,6 +316,11 @@ public function purchases(Request $request): JsonResponse ]); } + // 테넌트별 서버 모드 적용 + if ($barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + $userId = $barobillMember->barobill_id ?? ''; if (empty($userId)) { @@ -341,12 +396,16 @@ public function purchases(Request $request): JsonResponse // 작성일 기준으로 정렬 (최신순) usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); + // 마지막 매입 수집 시간 업데이트 + $barobillMember->update(['last_purchases_fetch_at' => now()]); + return response()->json([ 'success' => true, 'data' => [ 'invoices' => $allInvoices, 'pagination' => $lastPagination, - 'summary' => $totalSummary + 'summary' => $totalSummary, + 'lastFetchAt' => now()->format('Y-m-d H:i:s') ] ]); } catch (\Throwable $e) { @@ -491,6 +550,12 @@ public function diagnose(Request $request): JsonResponse try { $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + + // 테넌트별 서버 모드 적용 + if ($barobillMember && $barobillMember->server_mode) { + $this->applyMemberServerMode($barobillMember); + } + $userId = $barobillMember?->barobill_id ?? ''; $memberCorpNum = $barobillMember?->biz_no ?? ''; @@ -577,18 +642,24 @@ public function requestCollect(Request $request): JsonResponse } /** - * 수집 상태 확인 (미지원 안내) + * 수집 상태 확인 (마지막 수집 시간 조회) */ public function collectStatus(Request $request): JsonResponse { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $barobillMember = BarobillMember::where('tenant_id', $tenantId)->first(); + + $salesLastFetch = $barobillMember?->last_sales_fetch_at; + $purchasesLastFetch = $barobillMember?->last_purchases_fetch_at; + return response()->json([ 'success' => true, 'data' => [ - 'salesLastCollectDate' => '', - 'purchaseLastCollectDate' => '', + 'salesLastCollectDate' => $salesLastFetch ? $salesLastFetch->format('Y-m-d H:i') : '', + 'purchaseLastCollectDate' => $purchasesLastFetch ? $purchasesLastFetch->format('Y-m-d H:i') : '', 'isCollecting' => false, - 'collectStateText' => '확인 필요', - 'message' => '서비스 상태 진단 기능을 사용하여 홈택스 연동 상태를 확인해주세요.' + 'collectStateText' => ($salesLastFetch || $purchasesLastFetch) ? '조회 완료' : '조회 전', + 'message' => '매출/매입 탭을 클릭하면 데이터가 조회되고 수집 시간이 기록됩니다.' ] ]); } diff --git a/app/Http/Controllers/Finance/CorporateVehicleController.php b/app/Http/Controllers/Finance/CorporateVehicleController.php new file mode 100644 index 00000000..51eb31e2 --- /dev/null +++ b/app/Http/Controllers/Finance/CorporateVehicleController.php @@ -0,0 +1,166 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('finance.corporate-vehicles')); + } + + return view('finance.corporate-vehicles'); + } + + public function list(Request $request): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $query = CorporateVehicle::where('tenant_id', $tenantId); + + // 필터링 + if ($request->filled('ownership_type') && $request->ownership_type !== 'all') { + $query->where('ownership_type', $request->ownership_type); + } + + if ($request->filled('vehicle_type') && $request->vehicle_type !== 'all') { + $query->where('vehicle_type', $request->vehicle_type); + } + + if ($request->filled('status') && $request->status !== 'all') { + $query->where('status', $request->status); + } + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('plate_number', 'like', "%{$search}%") + ->orWhere('model', 'like', "%{$search}%") + ->orWhere('driver', 'like', "%{$search}%"); + }); + } + + $vehicles = $query->orderBy('created_at', 'desc')->get(); + + return response()->json([ + 'success' => true, + 'data' => $vehicles, + ]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'plate_number' => 'required|string|max:20', + 'model' => 'required|string|max:100', + 'vehicle_type' => 'required|string|max:20', + 'ownership_type' => 'required|in:corporate,rent,lease', + ]); + + $tenantId = session('tenant_id', 1); + + $vehicle = CorporateVehicle::create([ + 'tenant_id' => $tenantId, + 'plate_number' => $request->plate_number, + 'model' => $request->model, + 'vehicle_type' => $request->vehicle_type, + 'ownership_type' => $request->ownership_type, + 'year' => $request->year, + 'driver' => $request->driver, + 'status' => $request->status ?? 'active', + 'mileage' => $request->mileage ?? 0, + 'memo' => $request->memo, + // 법인차량 전용 + 'purchase_date' => $request->purchase_date, + 'purchase_price' => $request->purchase_price ?? 0, + // 렌트/리스 전용 + 'contract_date' => $request->contract_date, + 'rent_company' => $request->rent_company, + 'rent_company_tel' => $request->rent_company_tel, + 'rent_period' => $request->rent_period, + 'agreed_mileage' => $request->agreed_mileage, + 'vehicle_price' => $request->vehicle_price ?? 0, + 'residual_value' => $request->residual_value ?? 0, + 'deposit' => $request->deposit ?? 0, + 'monthly_rent' => $request->monthly_rent ?? 0, + 'monthly_rent_tax' => $request->monthly_rent_tax ?? 0, + 'insurance_company' => $request->insurance_company, + 'insurance_company_tel' => $request->insurance_company_tel, + ]); + + return response()->json([ + 'success' => true, + 'message' => '차량이 등록되었습니다.', + 'data' => $vehicle, + ]); + } + + public function update(Request $request, int $id): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $vehicle = CorporateVehicle::where('tenant_id', $tenantId)->findOrFail($id); + + $request->validate([ + 'plate_number' => 'required|string|max:20', + 'model' => 'required|string|max:100', + 'vehicle_type' => 'required|string|max:20', + 'ownership_type' => 'required|in:corporate,rent,lease', + ]); + + $vehicle->update([ + 'plate_number' => $request->plate_number, + 'model' => $request->model, + 'vehicle_type' => $request->vehicle_type, + 'ownership_type' => $request->ownership_type, + 'year' => $request->year, + 'driver' => $request->driver, + 'status' => $request->status ?? 'active', + 'mileage' => $request->mileage ?? 0, + 'memo' => $request->memo, + // 법인차량 전용 + 'purchase_date' => $request->purchase_date, + 'purchase_price' => $request->purchase_price ?? 0, + // 렌트/리스 전용 + 'contract_date' => $request->contract_date, + 'rent_company' => $request->rent_company, + 'rent_company_tel' => $request->rent_company_tel, + 'rent_period' => $request->rent_period, + 'agreed_mileage' => $request->agreed_mileage, + 'vehicle_price' => $request->vehicle_price ?? 0, + 'residual_value' => $request->residual_value ?? 0, + 'deposit' => $request->deposit ?? 0, + 'monthly_rent' => $request->monthly_rent ?? 0, + 'monthly_rent_tax' => $request->monthly_rent_tax ?? 0, + 'insurance_company' => $request->insurance_company, + 'insurance_company_tel' => $request->insurance_company_tel, + ]); + + return response()->json([ + 'success' => true, + 'message' => '차량 정보가 수정되었습니다.', + 'data' => $vehicle, + ]); + } + + public function destroy(int $id): JsonResponse + { + $tenantId = session('tenant_id', 1); + + $vehicle = CorporateVehicle::where('tenant_id', $tenantId)->findOrFail($id); + $vehicle->delete(); + + return response()->json([ + 'success' => true, + 'message' => '차량이 삭제되었습니다.', + ]); + } +} diff --git a/app/Http/Controllers/Sales/AdminProspectController.php b/app/Http/Controllers/Sales/AdminProspectController.php index 4360dd7d..b2b38b0e 100644 --- a/app/Http/Controllers/Sales/AdminProspectController.php +++ b/app/Http/Controllers/Sales/AdminProspectController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesCommission; use App\Models\Sales\SalesScenarioChecklist; use App\Models\Sales\SalesTenantManagement; use App\Models\Sales\TenantProspect; @@ -82,7 +83,7 @@ private function getIndexData(Request $request): array // 영업 역할을 가진 사용자 목록 (영업파트너) $salesPartners = User::whereHas('userRoles', function ($q) { $q->whereHas('role', function ($rq) { - $rq->whereIn('name', ['sales', 'manager', 'recruiter']); + $rq->whereIn('name', ['sales', 'manager']); }); })->orderBy('name')->get(); @@ -136,6 +137,14 @@ private function getIndexData(Request $request): array $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; + } } // 전체 통계 @@ -185,4 +194,87 @@ public function updateHqStatus(int $id, Request $request) 'hq_status_label' => $management->hq_status_label, ]); } + + /** + * 수당 날짜 기록/수정 + */ + public function updateCommissionDate(int $id, Request $request) + { + $this->checkAdminAccess(); + + $request->validate([ + 'field' => 'required|in:first_payment_at,first_partner_paid_at,second_payment_at,second_partner_paid_at,first_subscription_at,manager_paid_at', + 'date' => 'nullable|date', + ]); + + $prospect = TenantProspect::findOrFail($id); + $management = SalesTenantManagement::findOrCreateByProspect($prospect->id); + + // Commission 레코드 조회 또는 생성 + $commission = SalesCommission::firstOrCreate( + ['management_id' => $management->id], + [ + 'tenant_id' => $prospect->tenant_id ?? 1, + 'payment_type' => 'deposit', + 'payment_amount' => 0, + 'payment_date' => now(), + 'base_amount' => 0, + 'partner_rate' => 0, + 'manager_rate' => 0, + 'partner_commission' => 0, + 'manager_commission' => 0, + 'scheduled_payment_date' => now()->addMonth()->day(10), + 'status' => SalesCommission::STATUS_PENDING, + 'partner_id' => $management->sales_partner_id ?? 0, + 'manager_user_id' => $management->manager_user_id, + ] + ); + + $field = $request->input('field'); + $date = $request->input('date') ?: now()->format('Y-m-d'); + + $commission->update([ + $field => $date, + ]); + + return response()->json([ + 'success' => true, + 'field' => $field, + 'date' => $commission->$field?->format('Y-m-d'), + 'date_display' => $commission->$field?->format('m/d'), + ]); + } + + /** + * 수당 날짜 삭제 (초기화) + */ + public function clearCommissionDate(int $id, Request $request) + { + $this->checkAdminAccess(); + + $request->validate([ + 'field' => 'required|in:first_payment_at,first_partner_paid_at,second_payment_at,second_partner_paid_at,first_subscription_at,manager_paid_at', + ]); + + $prospect = TenantProspect::findOrFail($id); + $management = SalesTenantManagement::where('tenant_prospect_id', $prospect->id)->first(); + + if (!$management) { + return response()->json(['success' => false, 'message' => '관리 정보가 없습니다.']); + } + + $commission = SalesCommission::where('management_id', $management->id)->first(); + + if (!$commission) { + return response()->json(['success' => false, 'message' => '수당 정보가 없습니다.']); + } + + $field = $request->input('field'); + $commission->update([$field => null]); + + return response()->json([ + 'success' => true, + 'field' => $field, + ]); + } } diff --git a/app/Http/Controllers/Sales/SalesDashboardController.php b/app/Http/Controllers/Sales/SalesDashboardController.php index 53b523cd..15d9b88c 100644 --- a/app/Http/Controllers/Sales/SalesDashboardController.php +++ b/app/Http/Controllers/Sales/SalesDashboardController.php @@ -251,10 +251,8 @@ private function getDashboardData(Request $request): array ->get() ->keyBy('tenant_id'); - // 내가 유치한 영업파트너 목록 (드롭다운용) - $allManagers = auth()->user()->children() - ->where('is_active', true) - ->get(['id', 'name', 'email']); + // 상담매니저 역할을 가진 모든 사용자 (드롭다운용) + $allManagers = $this->getAllManagerUsers(); // 내가 매니저로만 참여하는 건 (다른 사람이 등록, 내가 매니저) $managerOnlyProspects = $this->getManagerOnlyProspects($currentUserId); @@ -412,10 +410,8 @@ public function refreshTenantList(Request $request): View ->get() ->keyBy('tenant_id'); - // 내가 유치한 영업파트너 목록 (드롭다운용) - $allManagers = auth()->user()->children() - ->where('is_active', true) - ->get(['id', 'name', 'email']); + // 상담매니저 역할을 가진 모든 사용자 (드롭다운용) + $allManagers = $this->getAllManagerUsers(); return view('sales.dashboard.partials.tenant-list', compact( 'tenants', @@ -768,6 +764,58 @@ private function getCommissionData(): array return compact('commissionSummary', 'recentCommissions', 'partner'); } + /** + * 상담매니저 역할을 가진 모든 사용자 조회 + * (tenant_id 조건 없이 - 파트너 관리 페이지와 동일한 방식) + */ + private function getAllManagerUsers() + { + return User::whereHas('userRoles.role', fn($q) => $q->where('name', 'manager')) + ->where('is_active', true) + ->where('id', '!=', auth()->id()) // 본인 제외 + ->get(['id', 'name', 'email']); + } + + /** + * 매니저 검색 API (AJAX) + * (tenant_id 조건 없이 - 모든 상담매니저 검색 가능) + */ + public function searchManagers(Request $request): JsonResponse + { + $query = $request->input('q', ''); + $authId = auth()->id(); + + // 디버깅: SQL 쿼리 로깅 + \DB::enableQueryLog(); + + $managers = User::whereHas('userRoles.role', fn($q) => $q->where('name', 'manager')) + ->where('is_active', true) + ->where('id', '!=', $authId) + ->when($query, function ($q) use ($query) { + $q->where(function ($subQ) use ($query) { + $subQ->where('name', 'like', "%{$query}%") + ->orWhere('email', 'like', "%{$query}%"); + }); + }) + ->limit(10) + ->get(['id', 'name', 'email']); + + $sqlLog = \DB::getQueryLog(); + + \Log::info('searchManagers 디버그', [ + 'query' => $query, + 'auth_id' => $authId, + 'result_count' => $managers->count(), + 'results' => $managers->pluck('name')->toArray(), + 'sql' => $sqlLog, + ]); + + return response()->json([ + 'success' => true, + 'managers' => $managers, + ]); + } + /** * 영업파트너 가이드북 도움말 모달 */ diff --git a/app/Http/Controllers/Sales/SalesManagerController.php b/app/Http/Controllers/Sales/SalesManagerController.php index 3d0e1e3b..c78ec003 100644 --- a/app/Http/Controllers/Sales/SalesManagerController.php +++ b/app/Http/Controllers/Sales/SalesManagerController.php @@ -261,7 +261,7 @@ public function delegateRole(Request $request, int $id) { $validated = $request->validate([ 'to_user_id' => 'required|exists:users,id', - 'role_name' => 'required|string|in:manager,recruiter', + 'role_name' => 'required|string|in:manager', ]); $fromUser = User::findOrFail($id); @@ -270,7 +270,7 @@ public function delegateRole(Request $request, int $id) try { $this->service->delegateRole($fromUser, $toUser, $validated['role_name']); - $roleLabel = $validated['role_name'] === 'manager' ? '매니저' : '유치담당'; + $roleLabel = '상담매니저'; return redirect()->back() ->with('success', "{$roleLabel} 역할이 {$toUser->name}님에게 위임되었습니다."); } catch (\InvalidArgumentException $e) { @@ -285,13 +285,13 @@ public function delegateRole(Request $request, int $id) public function assignRole(Request $request, int $id) { $validated = $request->validate([ - 'role_name' => 'required|string|in:sales,manager,recruiter', + 'role_name' => 'required|string|in:sales,manager', ]); $partner = User::findOrFail($id); $this->service->assignRole($partner, $validated['role_name']); - $roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당']; + $roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저']; return redirect()->back() ->with('success', "{$roleLabels[$validated['role_name']]} 역할이 부여되었습니다."); } @@ -302,13 +302,13 @@ public function assignRole(Request $request, int $id) public function removeRole(Request $request, int $id) { $validated = $request->validate([ - 'role_name' => 'required|string|in:sales,manager,recruiter', + 'role_name' => 'required|string|in:sales,manager', ]); $partner = User::findOrFail($id); $this->service->removeRole($partner, $validated['role_name']); - $roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당']; + $roleLabels = ['sales' => '영업파트너', 'manager' => '상담매니저']; return redirect()->back() ->with('success', "{$roleLabels[$validated['role_name']]} 역할이 제거되었습니다."); } diff --git a/app/Http/Controllers/Sales/SalesScenarioController.php b/app/Http/Controllers/Sales/SalesScenarioController.php index fee9fe69..d2acd2bd 100644 --- a/app/Http/Controllers/Sales/SalesScenarioController.php +++ b/app/Http/Controllers/Sales/SalesScenarioController.php @@ -228,6 +228,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View $steps = config('sales_scenario.manager_steps'); $currentStep = (int) $request->input('step', 1); $icons = config('sales_scenario.icons'); + $readonly = $request->boolean('readonly', false); // 가망고객 영업 관리 정보 조회 또는 생성 $management = SalesTenantManagement::findOrCreateByProspect($prospectId); @@ -250,6 +251,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View 'icons' => $icons, 'management' => $management, 'isProspect' => true, + 'readonly' => $readonly, ]); } @@ -262,6 +264,7 @@ public function prospectManagerScenario(int $prospectId, Request $request): View 'icons' => $icons, 'management' => $management, 'isProspect' => true, + 'readonly' => $readonly, ]); } diff --git a/app/Http/Controllers/Sales/TenantProspectController.php b/app/Http/Controllers/Sales/TenantProspectController.php index b0f7c30a..1a707b45 100644 --- a/app/Http/Controllers/Sales/TenantProspectController.php +++ b/app/Http/Controllers/Sales/TenantProspectController.php @@ -171,9 +171,10 @@ public function destroy(int $id) ->with('error', '이미 테넌트로 전환된 영업권은 삭제할 수 없습니다.'); } - // 본인 또는 관리자만 삭제 가능 - if ($prospect->registered_by !== auth()->id()) { - // TODO: 관리자 권한 체크 추가 + // 관리자만 삭제 가능 + if (!auth()->user()->isAdmin()) { + return redirect()->route('sales.prospects.index') + ->with('error', '삭제 권한이 없습니다. 본사 운영팀에 문의하세요.'); } $prospect->delete(); diff --git a/app/Models/Barobill/AccountCode.php b/app/Models/Barobill/AccountCode.php index aafa2c7d..7c295872 100644 --- a/app/Models/Barobill/AccountCode.php +++ b/app/Models/Barobill/AccountCode.php @@ -36,14 +36,31 @@ public function tenant(): BelongsTo } /** - * 테넌트별 활성 계정과목 조회 + * 테넌트별 활성 계정과목 조회 (하위 호환용) */ public static function getActiveByTenant(int $tenantId) { - return self::where('tenant_id', $tenantId) - ->where('is_active', true) + return self::getActive(); + } + + /** + * 전체 활성 계정과목 조회 (글로벌) + */ + public static function getActive() + { + return self::where('is_active', true) ->orderBy('sort_order') ->orderBy('code') ->get(); } + + /** + * 전체 계정과목 조회 (글로벌) + */ + public static function getAll() + { + return self::orderBy('sort_order') + ->orderBy('code') + ->get(); + } } diff --git a/app/Models/Barobill/BarobillConfig.php b/app/Models/Barobill/BarobillConfig.php index 4439350b..b63104fd 100644 --- a/app/Models/Barobill/BarobillConfig.php +++ b/app/Models/Barobill/BarobillConfig.php @@ -40,27 +40,25 @@ class BarobillConfig extends Model ]; /** - * 활성화된 테스트 서버 설정 조회 + * 테스트 서버 설정 조회 (환경 기준, is_active 무관) */ public static function getActiveTest(): ?self { - return self::where('environment', 'test') - ->where('is_active', true) - ->first(); + return self::where('environment', 'test')->first(); } /** - * 활성화된 운영 서버 설정 조회 + * 운영 서버 설정 조회 (환경 기준, is_active 무관) */ public static function getActiveProduction(): ?self { - return self::where('environment', 'production') - ->where('is_active', true) - ->first(); + return self::where('environment', 'production')->first(); } /** - * 현재 환경에 맞는 활성 설정 조회 + * 현재 환경에 맞는 설정 조회 + * + * 테넌트별 서버 모드 지원을 위해 is_active 조건 제거 */ public static function getActive(bool $isTestMode = false): ?self { diff --git a/app/Models/Barobill/BarobillMember.php b/app/Models/Barobill/BarobillMember.php index 7c087565..01d79f2e 100644 --- a/app/Models/Barobill/BarobillMember.php +++ b/app/Models/Barobill/BarobillMember.php @@ -27,6 +27,9 @@ class BarobillMember extends Model 'manager_email', 'manager_hp', 'status', + 'server_mode', + 'last_sales_fetch_at', + 'last_purchases_fetch_at', ]; protected $casts = [ @@ -34,6 +37,8 @@ class BarobillMember extends Model 'updated_at' => 'datetime', 'deleted_at' => 'datetime', 'barobill_pwd' => 'encrypted', // 복호화 가능한 암호화 (바로빌 API 호출 시 필요) + 'last_sales_fetch_at' => 'datetime', + 'last_purchases_fetch_at' => 'datetime', ]; protected $hidden = [ @@ -85,4 +90,36 @@ public function getStatusColorAttribute(): string default => 'bg-gray-100 text-gray-800', }; } + + /** + * 서버 모드 라벨 + */ + public function getServerModeLabelAttribute(): string + { + return match ($this->server_mode) { + 'test' => '테스트', + 'production' => '운영', + default => '테스트', + }; + } + + /** + * 서버 모드별 색상 클래스 + */ + public function getServerModeColorAttribute(): string + { + return match ($this->server_mode) { + 'test' => 'bg-amber-100 text-amber-800', + 'production' => 'bg-green-100 text-green-800', + default => 'bg-amber-100 text-amber-800', + }; + } + + /** + * 테스트 모드 여부 + */ + public function isTestMode(): bool + { + return $this->server_mode !== 'production'; + } } diff --git a/app/Models/CorporateVehicle.php b/app/Models/CorporateVehicle.php new file mode 100644 index 00000000..66a2dad1 --- /dev/null +++ b/app/Models/CorporateVehicle.php @@ -0,0 +1,51 @@ + 'integer', + 'mileage' => 'integer', + 'purchase_price' => 'integer', + 'vehicle_price' => 'integer', + 'residual_value' => 'integer', + 'deposit' => 'integer', + 'monthly_rent' => 'integer', + 'monthly_rent_tax' => 'integer', + ]; +} diff --git a/app/Models/Sales/SalesCommission.php b/app/Models/Sales/SalesCommission.php index 28c1bccb..3f56279d 100644 --- a/app/Models/Sales/SalesCommission.php +++ b/app/Models/Sales/SalesCommission.php @@ -20,6 +20,12 @@ * @property string $payment_type * @property float $payment_amount * @property string $payment_date + * @property string|null $first_payment_at 1차 납입완료일 + * @property string|null $first_partner_paid_at 1차 파트너 수당지급일 + * @property string|null $second_payment_at 2차 납입완료일 + * @property string|null $second_partner_paid_at 2차 파트너 수당지급일 + * @property string|null $first_subscription_at 첫 구독료 입금일 (매니저 수당 기준) + * @property string|null $manager_paid_at 매니저 수당지급일 * @property float $base_amount * @property float $partner_rate * @property float $manager_rate @@ -79,6 +85,12 @@ class SalesCommission extends Model 'payment_type', 'payment_amount', 'payment_date', + 'first_payment_at', + 'first_partner_paid_at', + 'second_payment_at', + 'second_partner_paid_at', + 'first_subscription_at', + 'manager_paid_at', 'base_amount', 'partner_rate', 'manager_rate', @@ -103,6 +115,12 @@ class SalesCommission extends Model 'partner_commission' => 'decimal:2', 'manager_commission' => 'decimal:2', 'payment_date' => 'date', + 'first_payment_at' => 'date', + 'first_partner_paid_at' => 'date', + 'second_payment_at' => 'date', + 'second_partner_paid_at' => 'date', + 'first_subscription_at' => 'date', + 'manager_paid_at' => 'date', 'scheduled_payment_date' => 'date', 'actual_payment_date' => 'date', 'approved_at' => 'datetime', @@ -290,4 +308,147 @@ public function scopePaymentDateBetween(Builder $query, string $startDate, strin { return $query->whereBetween('payment_date', [$startDate, $endDate]); } + + // ======================================== + // 2단계 수당 지급 관련 메서드 + // ======================================== + + /** + * 1차 납입완료 처리 + */ + public function recordFirstPayment(?Carbon $paymentDate = null): bool + { + return $this->update([ + 'first_payment_at' => $paymentDate ?? now(), + ]); + } + + /** + * 1차 파트너 수당 지급 처리 + */ + public function recordFirstPartnerPaid(?Carbon $paidDate = null): bool + { + if (!$this->first_payment_at) { + return false; // 1차 납입이 먼저 완료되어야 함 + } + + return $this->update([ + 'first_partner_paid_at' => $paidDate ?? now(), + ]); + } + + /** + * 2차 납입완료 처리 + */ + public function recordSecondPayment(?Carbon $paymentDate = null): bool + { + return $this->update([ + 'second_payment_at' => $paymentDate ?? now(), + ]); + } + + /** + * 2차 파트너 수당 지급 처리 + */ + public function recordSecondPartnerPaid(?Carbon $paidDate = null): bool + { + if (!$this->second_payment_at) { + return false; // 2차 납입이 먼저 완료되어야 함 + } + + return $this->update([ + 'second_partner_paid_at' => $paidDate ?? now(), + ]); + } + + /** + * 첫 구독료 입금 기록 (매니저 수당 기준) + */ + public function recordFirstSubscription(?Carbon $subscriptionDate = null): bool + { + return $this->update([ + 'first_subscription_at' => $subscriptionDate ?? now(), + ]); + } + + /** + * 매니저 수당 지급 처리 + */ + public function recordManagerPaid(?Carbon $paidDate = null): bool + { + if (!$this->first_subscription_at) { + return false; // 첫 구독료 입금이 먼저 완료되어야 함 + } + + return $this->update([ + 'manager_paid_at' => $paidDate ?? now(), + ]); + } + + /** + * 1차 파트너 수당 지급예정일 (1차 납입일 익월 10일) + */ + public function getFirstPartnerScheduledDateAttribute(): ?Carbon + { + if (!$this->first_payment_at) { + return null; + } + return Carbon::parse($this->first_payment_at)->addMonth()->day(10); + } + + /** + * 2차 파트너 수당 지급예정일 (2차 납입일 익월 10일) + */ + public function getSecondPartnerScheduledDateAttribute(): ?Carbon + { + if (!$this->second_payment_at) { + return null; + } + return Carbon::parse($this->second_payment_at)->addMonth()->day(10); + } + + /** + * 매니저 수당 지급예정일 (첫 구독료 입금일 익월 10일) + */ + public function getManagerScheduledDateAttribute(): ?Carbon + { + if (!$this->first_subscription_at) { + return null; + } + return Carbon::parse($this->first_subscription_at)->addMonth()->day(10); + } + + /** + * 파트너 수당 1차 지급 완료 여부 + */ + public function isFirstPartnerPaid(): bool + { + return $this->first_partner_paid_at !== null; + } + + /** + * 파트너 수당 2차 지급 완료 여부 + */ + public function isSecondPartnerPaid(): bool + { + return $this->second_partner_paid_at !== null; + } + + /** + * 매니저 수당 지급 완료 여부 + */ + public function isManagerPaid(): bool + { + return $this->manager_paid_at !== null; + } + + /** + * 전체 수당 지급 완료 여부 (파트너 1차/2차 + 매니저) + */ + public function isFullyPaid(): bool + { + return $this->isFirstPartnerPaid() + && $this->isSecondPartnerPaid() + && $this->isManagerPaid(); + } } diff --git a/app/Services/Barobill/BarobillService.php b/app/Services/Barobill/BarobillService.php index f61bc5bf..39b82202 100644 --- a/app/Services/Barobill/BarobillService.php +++ b/app/Services/Barobill/BarobillService.php @@ -96,9 +96,55 @@ class BarobillService public function __construct() { - // .env에서 테스트 모드 설정 가져오기 (기본값: true = 테스트 모드) + // 기본값: .env 설정 사용 $this->isTestMode = config('services.barobill.test_mode', true); + $this->initializeConfig(); + } + /** + * 서버 모드 전환 (회원사별 설정 적용) + * + * @param bool $isTestMode true: 테스트서버, false: 운영서버 + */ + public function switchServerMode(bool $isTestMode): self + { + if ($this->isTestMode !== $isTestMode) { + $this->isTestMode = $isTestMode; + // SOAP 클라이언트 초기화 (새 서버로 재연결) + $this->corpStateClient = null; + $this->tiClient = null; + $this->bankAccountClient = null; + $this->cardClient = null; + // 설정 재로드 + $this->initializeConfig(); + } + + return $this; + } + + /** + * 서버 모드 문자열로 전환 + * + * @param string $mode 'test' 또는 'production' + */ + public function setServerMode(string $mode): self + { + return $this->switchServerMode($mode === 'test'); + } + + /** + * 현재 서버 모드 조회 + */ + public function getServerMode(): string + { + return $this->isTestMode ? 'test' : 'production'; + } + + /** + * 설정 초기화 (서버 모드에 따른 설정 로드) + */ + protected function initializeConfig(): void + { // DB에서 활성화된 설정 가져오기 (우선순위) $dbConfig = $this->loadConfigFromDatabase(); diff --git a/app/Services/Barobill/BarobillUsageService.php b/app/Services/Barobill/BarobillUsageService.php index 7669fc13..9cc38e28 100644 --- a/app/Services/Barobill/BarobillUsageService.php +++ b/app/Services/Barobill/BarobillUsageService.php @@ -16,7 +16,7 @@ * 과금 정책 (DB 관리): * - 전자세금계산서: 기본 100건 무료, 추가 50건 단위 5,000원 * - 계좌조회: 기본 1계좌 무료, 추가 1계좌당 10,000원 - * - 카드등록: 기본 3장 무료, 추가 1장당 5,000원 + * - 카드등록: 기본 5장 무료, 추가 1장당 5,000원 */ class BarobillUsageService { @@ -270,12 +270,12 @@ public static function getPriceInfo(): array ], 'card' => [ 'name' => '법인카드 등록', - 'free_quota' => 3, + 'free_quota' => 5, 'free_quota_unit' => '장', 'additional_unit' => 1, 'additional_unit_label' => '장', 'additional_price' => 5000, - 'description' => '기본 3장 무료, 추가 1장당 5,000원', + 'description' => '기본 5장 무료, 추가 1장당 5,000원', ], 'hometax' => [ 'name' => '홈텍스 매입/매출', diff --git a/app/Services/Sales/SalesManagerService.php b/app/Services/Sales/SalesManagerService.php index c111b3e6..d3d1d54d 100644 --- a/app/Services/Sales/SalesManagerService.php +++ b/app/Services/Sales/SalesManagerService.php @@ -19,7 +19,7 @@ class SalesManagerService /** * 영업파트너 역할 이름 목록 */ - public const SALES_ROLES = ['sales', 'manager', 'recruiter']; + public const SALES_ROLES = ['sales', 'manager']; /** * 영업파트너 생성 @@ -510,9 +510,6 @@ public function getStats(?int $parentId = null): array 'manager' => (clone $baseQuery) ->whereHas('userRoles.role', fn($q) => $q->where('name', 'manager')) ->count(), - 'recruiter' => (clone $baseQuery) - ->whereHas('userRoles.role', fn($q) => $q->where('name', 'recruiter')) - ->count(), ]; } diff --git a/database/seeders/BarobillPricingPolicySeeder.php b/database/seeders/BarobillPricingPolicySeeder.php index 33324568..ff90105d 100644 --- a/database/seeders/BarobillPricingPolicySeeder.php +++ b/database/seeders/BarobillPricingPolicySeeder.php @@ -16,8 +16,8 @@ public function run(): void [ 'service_type' => 'card', 'name' => '법인카드 등록', - 'description' => '법인카드 등록 기본 3장 제공, 추가 시 장당 과금', - 'free_quota' => 3, + 'description' => '법인카드 등록 기본 5장 제공, 추가 시 장당 과금', + 'free_quota' => 5, 'free_quota_unit' => '장', 'additional_unit' => 1, 'additional_unit_label' => '장', diff --git a/database/seeders/PartnerMenuRenameSeeder.php b/database/seeders/PartnerMenuRenameSeeder.php new file mode 100644 index 00000000..2b2efa90 --- /dev/null +++ b/database/seeders/PartnerMenuRenameSeeder.php @@ -0,0 +1,31 @@ +where('name', '영업파트너 관리') + ->first(); + + if ($menu) { + $menu->name = '파트너 관리'; + $menu->save(); + $this->command->info('메뉴 이름 변경: 영업파트너 관리 → 파트너 관리'); + } else { + $this->command->warn('영업파트너 관리 메뉴를 찾을 수 없습니다.'); + } + } +} diff --git a/database/seeders/SalesRoleSeeder.php b/database/seeders/SalesRoleSeeder.php index 3fc3e9a4..ca731dd7 100644 --- a/database/seeders/SalesRoleSeeder.php +++ b/database/seeders/SalesRoleSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Models\Role; +use App\Models\UserRole; use Illuminate\Database\Seeder; class SalesRoleSeeder extends Seeder @@ -20,15 +21,11 @@ public function run(): void $roles = [ [ 'name' => 'sales', - 'description' => '영업 - 가망고객 발굴, 상담, 계약 체결', + 'description' => '영업파트너 - 고객 발굴, 계약 체결', ], [ 'name' => 'manager', - 'description' => '매니저 - 하위 파트너 관리, 실적 취합, 승인 처리', - ], - [ - 'name' => 'recruiter', - 'description' => '유치담당 - 새로운 영업파트너 유치 활동', + 'description' => '상담매니저 - 고객 상담, 인터뷰 정리', ], ]; @@ -45,6 +42,23 @@ public function run(): void ); } - $this->command->info('영업파트너 역할이 생성되었습니다: sales, manager, recruiter'); + // recruiter 역할 삭제 (더 이상 사용하지 않음) + $recruiterRole = Role::where('tenant_id', $tenantId) + ->where('name', 'recruiter') + ->first(); + + if ($recruiterRole) { + // 해당 역할을 가진 user_roles 강제 삭제 (soft delete 포함) + UserRole::withTrashed() + ->where('role_id', $recruiterRole->id) + ->forceDelete(); + $this->command->info('recruiter 역할 할당이 삭제되었습니다.'); + + // 역할 강제 삭제 + $recruiterRole->forceDelete(); + $this->command->info('recruiter 역할이 삭제되었습니다.'); + } + + $this->command->info('영업파트너 역할이 생성되었습니다: sales, manager'); } } diff --git a/docs/TABLE_LAYOUT_STANDARD.md b/docs/TABLE_LAYOUT_STANDARD.md index f4665e8d..820a8521 100644 --- a/docs/TABLE_LAYOUT_STANDARD.md +++ b/docs/TABLE_LAYOUT_STANDARD.md @@ -656,14 +656,147 @@ #### `resources/views/products/partials/table.blade.php` --- -## 10. 문서 이력 +## 10. React 테이블 (Blade + Babel) + +> **참조 파일**: `resources/views/barobill/etax/index.blade.php` +> **작성일**: 2026-02-03 + +Blade 템플릿 내에서 React(Babel)를 사용하는 경우, 테이블 컬럼 너비 설정 시 주의해야 할 사항이 있습니다. + +### 10.1 colgroup을 사용한 컬럼 너비 지정 + +React에서 `table-fixed` 레이아웃과 함께 컬럼 너비를 정확하게 지정하려면 `colgroup`을 사용해야 합니다. Tailwind의 `w-[]` 클래스만으로는 정확한 너비 적용이 어려울 수 있습니다. + +#### 잘못된 예시 (Tailwind 클래스만 사용) + +```jsx +// ❌ 테이블 셀에 Tailwind 클래스만 적용 - 비율이 의도대로 안 될 수 있음 + + + + + + + + +
품목명수량단가
+``` + +#### 올바른 예시 (colgroup 사용) + +```jsx +// ✅ colgroup으로 명시적 너비 지정 + + + {/* 품목명 - 가장 넓게 */} + {/* 수량 - 작게 고정 */} + {/* 단가 - 수량보다 넓게 */} + {/* 공급가액 */} + {/* 세액 */} + {/* 금액 */} + {/* 과세 (select) */} + {/* 삭제 버튼 */} + + + + + + + {/* ... */} + + +
품목명수량단가
+``` + +### 10.2 Blade 템플릿에서 React 스타일 객체 이스케이프 + +**중요**: Blade 템플릿(`.blade.php`)에서 React의 스타일 객체 `{{ }}`를 사용하면 Blade가 이를 PHP echo 구문으로 해석하여 에러가 발생합니다. + +#### 에러 발생 코드 + +```jsx +// ❌ Blade가 {{ }}를 PHP 변수로 해석 → 에러 발생 + +// Error: Unknown named parameter $tableLayout +``` + +#### 해결 방법: `@{{ }}` 사용 + +```jsx +// ✅ @를 붙여 Blade 이스케이프 처리 +
++``` + +`@{{ }}`를 사용하면 Blade가 해당 구문을 처리하지 않고 그대로 `{{ }}`로 출력하여 React/Babel이 정상적으로 해석합니다. + +### 10.3 입력 테이블 컬럼 비율 가이드 + +품목 입력 테이블의 권장 컬럼 비율: + +| 컬럼 | 너비 | 설명 | +|------|------|------| +| 품목명 | **30%** | 텍스트 입력, 가장 넓게 | +| 수량 | **60px** | 작은 숫자 입력, 고정 너비 | +| 단가 | **100px** | 금액 입력, 수량보다 넓게 | +| 공급가액 | **12%** | 계산된 금액 표시 | +| 세액 | **10%** | 계산된 금액 표시 | +| 금액 | **12%** | 합계 금액 표시 | +| 과세유형 | **70px** | select 박스 | +| 삭제 | **40px** | 버튼 | + +#### 비율 설정 원칙 + +1. **입력 필드**는 내용에 맞는 적절한 너비 확보 +2. **수량**은 보통 작은 숫자이므로 좁게 (60px) +3. **단가/금액**은 큰 숫자를 표시하므로 넉넉하게 +4. **품목명**은 텍스트 입력이므로 가장 넓게 (%) +5. **버튼/아이콘**은 고정 픽셀 (px) + +### 10.4 전체 예제 코드 + +```jsx +// Blade 템플릿 내 React 코드 (@push('scripts') 내부) +
+ + + + + + + + + + + + + + + + + + + + + + + + {/* 데이터 행들 */} + +
품목명수량단가공급가액세액금액과세
+``` + +--- + +## 11. 문서 이력 | 버전 | 날짜 | 작성자 | 변경 내용 | |------|------|--------|----------| | 1.0 | 2025-11-25 | Claude | 초안 작성 (권한 관리 페이지 기반) | +| 1.1 | 2026-02-03 | Claude | React 테이블 섹션 추가 (colgroup, Blade 이스케이프) | --- -## 11. 문의 +## 12. 문의 이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요. \ No newline at end of file diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 96a9bbee..1f123450 100644 --- a/resources/views/barobill/eaccount/index.blade.php +++ b/resources/views/barobill/eaccount/index.blade.php @@ -80,6 +80,14 @@ const CSRF_TOKEN = '{{ csrf_token() }}'; + // 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준 + const formatLocalDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + // 날짜 유틸리티 함수 const getMonthDates = (offset = 0) => { const now = new Date(); @@ -88,8 +96,8 @@ const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); return { - from: firstDay.toISOString().split('T')[0], - to: lastDay.toISOString().split('T')[0] + from: formatLocalDate(firstDay), + to: formatLocalDate(lastDay) }; }; @@ -102,53 +110,46 @@ } }; - // StatCard Component - const StatCard = ({ title, value, subtext, icon, color = 'blue' }) => { + // CompactStat Component (큰 크기 통계 표시) + const CompactStat = ({ label, value, color = 'stone' }) => { const colorClasses = { - blue: 'bg-blue-50 text-blue-600', - green: 'bg-green-50 text-green-600', - red: 'bg-red-50 text-red-600', - stone: 'bg-stone-50 text-stone-600' + blue: 'text-blue-600', + green: 'text-green-600', + red: 'text-red-600', + stone: 'text-stone-700' }; return ( -
-
-

{title}

-
- {icon} -
-
-
{value}
- {subtext &&
{subtext}
} +
+ {label} + {value}
); }; - // AccountSelector Component + // AccountSelector Component (컴팩트 버전) const AccountSelector = ({ accounts, selectedAccount, onSelect }) => ( -
+
{accounts.map(acc => ( ))}
@@ -158,7 +159,9 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ const AccountCodeSelect = ({ value, onChange, accountCodes }) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); const containerRef = useRef(null); + const listRef = useRef(null); // 선택된 값의 표시 텍스트 const selectedItem = accountCodes.find(c => c.code === value); @@ -172,12 +175,18 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ code.name.toLowerCase().includes(searchLower); }); + // 검색어 변경 시 하이라이트 초기화 + useEffect(() => { + setHighlightIndex(-1); + }, [search]); + // 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { setIsOpen(false); setSearch(''); + setHighlightIndex(-1); } }; document.addEventListener('mousedown', handleClickOutside); @@ -189,12 +198,48 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ onChange(code.code, selected?.name || ''); setIsOpen(false); setSearch(''); + setHighlightIndex(-1); }; const handleClear = (e) => { e.stopPropagation(); onChange('', ''); setSearch(''); + setHighlightIndex(-1); + }; + + // 키보드 네비게이션 + const handleKeyDown = (e) => { + const maxIndex = Math.min(filteredCodes.length, 50) - 1; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0; + setHighlightIndex(newIndex); + // 스크롤 따라가기 + setTimeout(() => { + if (listRef.current && listRef.current.children[newIndex]) { + listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' }); + } + }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex; + setHighlightIndex(newIndex); + setTimeout(() => { + if (listRef.current && listRef.current.children[newIndex]) { + listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' }); + } + }, 0); + } else if (e.key === 'Enter' && filteredCodes.length > 0) { + e.preventDefault(); + const selectIndex = highlightIndex >= 0 ? highlightIndex : 0; + handleSelect(filteredCodes[selectIndex]); + } else if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + } }; return ( @@ -235,27 +280,32 @@ className="text-stone-400 hover:text-stone-600" type="text" value={search} onChange={(e) => setSearch(e.target.value)} + onKeyDown={handleKeyDown} placeholder="코드 또는 이름 검색..." className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-emerald-500 outline-none" autoFocus />
{/* 옵션 목록 */} -
+
{filteredCodes.length === 0 ? (
검색 결과 없음
) : ( - filteredCodes.slice(0, 50).map(code => ( + filteredCodes.slice(0, 50).map((code, index) => (
handleSelect(code)} - className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-emerald-50 ${ - value === code.code ? 'bg-emerald-100 text-emerald-700' : 'text-stone-700' + className={`px-3 py-1.5 text-xs cursor-pointer ${ + index === highlightIndex + ? 'bg-emerald-600 text-white font-semibold' + : value === code.code + ? 'bg-emerald-100 text-emerald-700' + : 'text-stone-700 hover:bg-emerald-50' }`} > - {code.code} + {code.code} {code.name}
)) @@ -576,7 +626,7 @@ className="px-6 py-2 bg-stone-600 text-white rounded-lg hover:bg-stone-700 font- } return ( -
+

입출금 내역

@@ -665,7 +715,7 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded
-
+
@@ -929,6 +979,13 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
@if($isTestMode) 테스트 모드 + @else + + + + + 운영 모드 + @endif @if($hasSoapClient) SOAP 연결됨 @@ -938,49 +995,31 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
- {/* Dashboard */} -
- } - color="blue" - /> - } - color="red" - /> - } - color="green" - /> - } - color="stone" - /> -
+ {/* 통계 + 계좌 선택 (한 줄) */} +
+
+ {/* 통계 배지들 */} + + + + - {/* Account Filter */} - {accounts.length > 0 && ( -
-

계좌 선택

- + {/* 구분선 */} + {accounts.length > 0 &&
} + + {/* 계좌 선택 버튼들 */} + {accounts.length > 0 && ( + <> + 계좌: + + + )}
- )} +
{/* Error Display */} {error && ( diff --git a/resources/views/barobill/ecard/index.blade.php b/resources/views/barobill/ecard/index.blade.php index 708b00d0..e3e048fe 100644 --- a/resources/views/barobill/ecard/index.blade.php +++ b/resources/views/barobill/ecard/index.blade.php @@ -79,21 +79,29 @@ const CSRF_TOKEN = '{{ csrf_token() }}'; + // 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준 + const formatLocalDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + // 날짜 유틸리티 함수 const getMonthDates = (offset = 0) => { const now = new Date(); - const today = now.toISOString().split('T')[0]; + const today = formatLocalDate(now); const year = now.getFullYear(); const month = now.getMonth() + offset; const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); // 종료일: 이번달이면 오늘, 지난달이면 그 달의 마지막 날 - const lastDayStr = lastDay.toISOString().split('T')[0]; + const lastDayStr = formatLocalDate(lastDay); const endDate = offset >= 0 && lastDayStr > today ? today : lastDayStr; return { - from: firstDay.toISOString().split('T')[0], + from: formatLocalDate(firstDay), to: endDate }; }; @@ -163,7 +171,9 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ const AccountCodeSelect = ({ value, onChange, accountCodes }) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); const containerRef = useRef(null); + const listRef = useRef(null); // 선택된 값의 표시 텍스트 const selectedItem = accountCodes.find(c => c.code === value); @@ -177,12 +187,18 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ code.name.toLowerCase().includes(searchLower); }); + // 검색어 변경 시 하이라이트 초기화 + useEffect(() => { + setHighlightIndex(-1); + }, [search]); + // 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { setIsOpen(false); setSearch(''); + setHighlightIndex(-1); } }; document.addEventListener('mousedown', handleClickOutside); @@ -194,12 +210,47 @@ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ onChange(code.code, selected?.name || ''); setIsOpen(false); setSearch(''); + setHighlightIndex(-1); }; const handleClear = (e) => { e.stopPropagation(); onChange('', ''); setSearch(''); + setHighlightIndex(-1); + }; + + // 키보드 네비게이션 + const handleKeyDown = (e) => { + const maxIndex = Math.min(filteredCodes.length, 50) - 1; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const newIndex = highlightIndex < maxIndex ? highlightIndex + 1 : 0; + setHighlightIndex(newIndex); + setTimeout(() => { + if (listRef.current && listRef.current.children[newIndex]) { + listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' }); + } + }, 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const newIndex = highlightIndex > 0 ? highlightIndex - 1 : maxIndex; + setHighlightIndex(newIndex); + setTimeout(() => { + if (listRef.current && listRef.current.children[newIndex]) { + listRef.current.children[newIndex].scrollIntoView({ block: 'nearest' }); + } + }, 0); + } else if (e.key === 'Enter' && filteredCodes.length > 0) { + e.preventDefault(); + const selectIndex = highlightIndex >= 0 ? highlightIndex : 0; + handleSelect(filteredCodes[selectIndex]); + } else if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + } }; return ( @@ -233,34 +284,39 @@ className="text-stone-400 hover:text-stone-600" {/* 드롭다운 */} {isOpen && ( -
+
{/* 검색 입력 */}
setSearch(e.target.value)} + onKeyDown={handleKeyDown} placeholder="코드 또는 이름 검색..." className="w-full px-2 py-1 text-xs border border-stone-200 rounded focus:ring-1 focus:ring-purple-500 outline-none" autoFocus />
{/* 옵션 목록 */} -
+
{filteredCodes.length === 0 ? (
검색 결과 없음
) : ( - filteredCodes.slice(0, 50).map(code => ( + filteredCodes.slice(0, 50).map((code, index) => (
handleSelect(code)} - className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-purple-50 ${ - value === code.code ? 'bg-purple-100 text-purple-700' : 'text-stone-700' + className={`px-3 py-1.5 text-xs cursor-pointer ${ + index === highlightIndex + ? 'bg-purple-600 text-white font-semibold' + : value === code.code + ? 'bg-purple-100 text-purple-700' + : 'text-stone-700 hover:bg-purple-50' }`} > - {code.code} + {code.code} {code.name}
)) @@ -575,7 +631,7 @@ className="flex-1 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 t } return ( -
+

카드 사용내역

@@ -663,7 +719,7 @@ className="flex items-center gap-2 px-4 py-2 bg-blue-100 text-blue-700 rounded-l
-
+
@@ -1199,6 +1255,13 @@ className="text-xs text-amber-600 hover:text-amber-700 underline"
@if($isTestMode) 테스트 모드 + @else + + + + + 운영 모드 + @endif @if($hasSoapClient) SOAP 연결됨 diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 43025df3..085bdb41 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -101,6 +101,17 @@ isHeadquarters: {{ ($currentTenant?->id ?? 0) == 1 ? 'true' : 'false' }} }; + // 서버 모드 정보 + const IS_TEST_MODE = {{ $isTestMode ? 'true' : 'false' }}; + + // 로컬 타임존 기준 날짜 포맷 (YYYY-MM-DD) - 한국 시간 기준 + const formatLocalDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + // StatCard Component const StatCard = ({ title, value, subtext, icon }) => (
@@ -145,7 +156,7 @@ recipientAddr: randomRecipient.addr, recipientContact: randomRecipient.contact || '홍길동', recipientEmail: randomRecipient.email, - supplyDate: supplyDate.toISOString().split('T')[0], + supplyDate: formatLocalDate(supplyDate), items, memo: '' }; @@ -259,17 +270,27 @@
-
+
+ + + + + + + + + + - - - - - - - + + + + + + + @@ -352,15 +373,33 @@
품목명수량단가공급가액세액금액과세수량단가공급가액세액금액과세