diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index a121f87d..7cbd191c 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -10,6 +10,7 @@ use App\Models\Barobill\BankTransaction; use App\Models\Barobill\BankTransactionOverride; use App\Models\Barobill\BankTransactionSplit; +use App\Models\Barobill\Client; use App\Models\Tenants\Tenant; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -618,6 +619,9 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '', // 저장된 계정과목 정보 병합 'accountCode' => $savedItem?->account_code ?? '', 'accountName' => $savedItem?->account_name ?? '', + // 저장된 거래처 정보 병합 + 'clientCode' => $savedItem?->client_code ?? '', + 'clientName' => $savedItem?->client_name ?? '', 'isSaved' => $savedItem !== null, 'isOverridden' => $override !== null, 'uniqueKey' => $uniqueKey, @@ -874,6 +878,8 @@ private function convertDbToRawLog(BankTransaction $record): \stdClass $log->TransType = ''; $log->BankName = $record->bank_name ?? ''; $log->BankCode = $record->bank_code ?? ''; + $log->ClientCode = $record->client_code ?? ''; + $log->ClientName = $record->client_name ?? ''; return $log; } @@ -929,6 +935,40 @@ private function getBankName(string $code): string return $banks[$code] ?? $code; } + /** + * 거래처 검색 API + */ + public function searchClients(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $keyword = trim($request->input('q', '')); + + if (empty($keyword)) { + return response()->json([ + 'success' => true, + 'data' => [] + ]); + } + + $clients = Client::searchByCodeOrName($tenantId, $keyword); + + return response()->json([ + 'success' => true, + 'data' => $clients->map(fn($c) => [ + 'code' => $c->client_code, + 'name' => $c->name, + ]) + ]); + } catch (\Throwable $e) { + Log::error('거래처 검색 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '검색 오류: ' . $e->getMessage() + ]); + } + } + /** * 계정과목 목록 조회 (글로벌 데이터) */ @@ -1144,6 +1184,8 @@ public function save(Request $request): JsonResponse 'trans_office' => $trans['transOffice'] ?? '', 'account_code' => $trans['accountCode'] ?? null, 'account_name' => $trans['accountName'] ?? null, + 'client_code' => $trans['clientCode'] ?? null, + 'client_name' => $trans['clientName'] ?? null, 'updated_at' => now(), ]); Log::info('[Eaccount Save] 수동 거래 업데이트', [ @@ -1175,6 +1217,8 @@ public function save(Request $request): JsonResponse 'trans_office' => $trans['transOffice'] ?? '', 'account_code' => $trans['accountCode'] ?? null, 'account_name' => $trans['accountName'] ?? null, + 'client_code' => $trans['clientCode'] ?? null, + 'client_name' => $trans['clientName'] ?? null, ]; // 고유 키 생성 (오버라이드 동기화용) @@ -1217,6 +1261,8 @@ public function save(Request $request): JsonResponse 'trans_office' => $data['trans_office'], 'account_code' => $data['account_code'], 'account_name' => $data['account_name'], + 'client_code' => $data['client_code'], + 'client_name' => $data['client_name'], 'updated_at' => now(), ]); $updated++; @@ -1877,6 +1923,8 @@ private function convertManualToLogs($manualTransactions): array 'transOffice' => $t->trans_office ?? '', 'accountCode' => $t->account_code ?? '', 'accountName' => $t->account_name ?? '', + 'clientCode' => $t->client_code ?? '', + 'clientName' => $t->client_name ?? '', 'isSaved' => true, 'isOverridden' => false, 'isManual' => true, diff --git a/app/Models/Barobill/BankTransaction.php b/app/Models/Barobill/BankTransaction.php index b9e7509c..c9833a5e 100644 --- a/app/Models/Barobill/BankTransaction.php +++ b/app/Models/Barobill/BankTransaction.php @@ -32,6 +32,8 @@ class BankTransaction extends Model 'trans_office', 'account_code', 'account_name', + 'client_code', + 'client_name', 'is_manual', ]; diff --git a/app/Models/Barobill/Client.php b/app/Models/Barobill/Client.php new file mode 100644 index 00000000..89e490b3 --- /dev/null +++ b/app/Models/Barobill/Client.php @@ -0,0 +1,31 @@ +where('is_active', true) + ->where(function ($query) use ($keyword) { + $query->where('client_code', 'like', "%{$keyword}%") + ->orWhere('name', 'like', "%{$keyword}%"); + }) + ->select('client_code', 'name') + ->orderBy('client_code') + ->limit($limit) + ->get(); + } +} diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 30edf791..390f0867 100644 --- a/resources/views/barobill/eaccount/index.blade.php +++ b/resources/views/barobill/eaccount/index.blade.php @@ -83,6 +83,7 @@ splits: '{{ route("barobill.eaccount.splits") }}', saveSplits: '{{ route("barobill.eaccount.splits.save") }}', deleteSplits: '{{ route("barobill.eaccount.splits.delete") }}', + clientsSearch: '{{ route("barobill.eaccount.clients.search") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -330,6 +331,195 @@ className={`px-3 py-1.5 text-xs cursor-pointer ${ ); }; + // ClientCodeSelect Component (서버 검색 기반 드롭다운) + const ClientCodeSelect = ({ value, clientName, onChange }) => { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const containerRef = useRef(null); + const listRef = useRef(null); + const debounceRef = useRef(null); + + // 표시 텍스트 + const displayText = value ? `${value} ${clientName || ''}` : ''; + + // 검색어 변경 시 debounce API 호출 + useEffect(() => { + if (!isOpen) return; + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (!search || search.length < 1) { + setResults([]); + setHighlightIndex(-1); + return; + } + + setSearching(true); + debounceRef.current = setTimeout(async () => { + try { + const res = await fetch(`${API.clientsSearch}?q=${encodeURIComponent(search)}`); + const data = await res.json(); + if (data.success) { + setResults(data.data || []); + } + } catch (err) { + console.error('거래처 검색 오류:', err); + } finally { + setSearching(false); + setHighlightIndex(-1); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [search, isOpen]); + + // 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + setResults([]); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (item) => { + onChange(item.code, item.name); + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + setResults([]); + }; + + const handleClear = (e) => { + e.stopPropagation(); + onChange('', ''); + setSearch(''); + setHighlightIndex(-1); + setResults([]); + }; + + // 키보드 네비게이션 + const handleKeyDown = (e) => { + const maxIndex = results.length - 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' && results.length > 0) { + e.preventDefault(); + const selectIndex = highlightIndex >= 0 ? highlightIndex : 0; + handleSelect(results[selectIndex]); + } else if (e.key === 'Escape') { + setIsOpen(false); + setSearch(''); + setHighlightIndex(-1); + setResults([]); + } + }; + + return ( +
+ {/* 선택 버튼 */} +
setIsOpen(!isOpen)} + className={`w-full px-2 py-1 text-xs border rounded cursor-pointer flex items-center justify-between gap-1 ${ + isOpen ? 'border-blue-500 ring-2 ring-blue-500' : 'border-stone-200' + } bg-white`} + > + + {displayText || '거래처'} + +
+ {value && ( + + )} + + + +
+
+ + {/* 드롭다운 */} + {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-blue-500 outline-none" + autoFocus + /> +
+ {/* 옵션 목록 */} +
+ {searching ? ( +
+ 검색 중... +
+ ) : results.length === 0 ? ( +
+ {search ? '검색 결과 없음' : '검색어를 입력하세요'} +
+ ) : ( + results.map((item, index) => ( +
handleSelect(item)} + className={`px-3 py-1.5 text-xs cursor-pointer ${ + index === highlightIndex + ? 'bg-blue-600 text-white font-semibold' + : value === item.code + ? 'bg-blue-100 text-blue-700' + : 'text-stone-700 hover:bg-blue-50' + }`} + > + {item.code} + {item.name} +
+ )) + )} +
+
+ )} +
+ ); + }; + // AccountCodeSettingsModal Component const AccountCodeSettingsModal = ({ isOpen, onClose, onUpdate }) => { const [codes, setCodes] = useState([]); @@ -1423,6 +1613,7 @@ className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 t onManualNew, onManualEdit, onManualDelete, + onClientCodeChange, splits, onOpenSplitModal, onDeleteSplits @@ -1572,13 +1763,14 @@ className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded 잔액 취급점 상대계좌예금주명 + 거래처코드 관리 {logs.length === 0 ? ( - + 해당 기간에 조회된 입출금 내역이 없습니다. @@ -1640,6 +1832,13 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2 placeholder="예금주명 입력" /> + + onClientCodeChange(index, code, name)} + /> + {log.isManual && (
@@ -1687,6 +1886,7 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" + ))} @@ -1920,6 +2120,20 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" setHasChanges(true); }, []); + // 거래처코드 변경 핸들러 + const handleClientCodeChange = useCallback((index, code, name) => { + setLogs(prevLogs => { + const newLogs = [...prevLogs]; + newLogs[index] = { + ...newLogs[index], + clientCode: code, + clientName: name + }; + return newLogs; + }); + setHasChanges(true); + }, []); + // 거래 수정 모달 열기 const handleEditTransaction = useCallback((index) => { setEditingLogIndex(index); @@ -2190,6 +2404,7 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" onManualNew={handleManualNew} onManualEdit={handleManualEdit} onManualDelete={handleManualDelete} + onClientCodeChange={handleClientCodeChange} splits={splits} onOpenSplitModal={handleOpenSplitModal} onDeleteSplits={handleDeleteSplits} diff --git a/routes/web.php b/routes/web.php index 63a1c26a..c02e8141 100644 --- a/routes/web.php +++ b/routes/web.php @@ -540,6 +540,8 @@ Route::get('/splits', [\App\Http\Controllers\Barobill\EaccountController::class, 'splits'])->name('splits'); Route::post('/splits', [\App\Http\Controllers\Barobill\EaccountController::class, 'saveSplits'])->name('splits.save'); Route::delete('/splits', [\App\Http\Controllers\Barobill\EaccountController::class, 'deleteSplits'])->name('splits.delete'); + // 거래처 검색 + Route::get('/clients/search', [\App\Http\Controllers\Barobill\EaccountController::class, 'searchClients'])->name('clients.search'); }); // 카드 사용내역 (재무관리로 이동됨 - 데이터 API만 유지)