feat:계좌 입출금내역 거래처코드 검색 기능 추가

- Client 모델 생성 (거래처 검색용)
- EaccountController에 searchClients API 추가
- save/parseTransactionLogs/convertManualToLogs/convertDbToRawLog에 client_code/client_name 필드 추가
- ClientCodeSelect 컴포넌트 추가 (서버 검색 기반 debounce 드롭다운)
- 테이블에 거래처코드 컬럼 추가
- BankTransaction 모델 fillable에 client_code/client_name 추가
- 라우트에 clients/search 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-19 21:18:31 +09:00
parent 1686198fcd
commit 072268a1cf
5 changed files with 299 additions and 1 deletions

View File

@@ -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,

View File

@@ -32,6 +32,8 @@ class BankTransaction extends Model
'trans_office',
'account_code',
'account_name',
'client_code',
'client_name',
'is_manual',
];

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
/**
* 거래처 모델 (검색용)
* clients 테이블은 API 프로젝트에서 관리하며, MNG에서는 검색 조회만 수행
*/
class Client extends Model
{
protected $table = 'clients';
/**
* 거래처코드 또는 거래처명으로 검색
*/
public static function searchByCodeOrName(int $tenantId, string $keyword, int $limit = 20)
{
return self::where('tenant_id', $tenantId)
->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();
}
}

View File

@@ -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 (
<div ref={containerRef} className="relative">
{/* 선택 버튼 */}
<div
onClick={() => 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`}
>
<span className={displayText ? 'text-stone-900 truncate' : 'text-stone-400'}>
{displayText || '거래처'}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{value && (
<button
onClick={handleClear}
className="text-stone-400 hover:text-stone-600"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<svg className={`w-3 h-3 text-stone-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* 드롭다운 */}
{isOpen && (
<div className="absolute z-50 mt-1 w-56 bg-white border border-stone-200 rounded-lg shadow-lg">
{/* 검색 입력 */}
<div className="p-2 border-b border-stone-100">
<input
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-blue-500 outline-none"
autoFocus
/>
</div>
{/* 옵션 목록 */}
<div ref={listRef} className="max-h-48 overflow-y-auto">
{searching ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">
검색 ...
</div>
) : results.length === 0 ? (
<div className="px-3 py-2 text-xs text-stone-400 text-center">
{search ? '검색 결과 없음' : '검색어를 입력하세요'}
</div>
) : (
results.map((item, index) => (
<div
key={item.code}
onClick={() => 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'
}`}
>
<span className={`font-mono ${index === highlightIndex ? 'text-white' : 'text-blue-600'}`}>{item.code}</span>
<span className="ml-1">{item.name}</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
};
// 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
<th className="px-4 py-4 text-right bg-stone-50">잔액</th>
<th className="px-4 py-4 bg-stone-50">취급점</th>
<th className="px-4 py-4 bg-stone-50 min-w-[120px]">상대계좌예금주명</th>
<th className="px-4 py-4 bg-stone-50 min-w-[140px]">거래처코드</th>
<th className="px-4 py-4 bg-stone-50 text-center w-[80px]">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{logs.length === 0 ? (
<tr>
<td colSpan="10" className="px-6 py-8 text-center text-stone-400">
<td colSpan="11" className="px-6 py-8 text-center text-stone-400">
해당 기간에 조회된 입출금 내역이 없습니다.
</td>
</tr>
@@ -1640,6 +1832,13 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
placeholder="예금주명 입력"
/>
</td>
<td className="px-4 py-3">
<ClientCodeSelect
value={log.clientCode || ''}
clientName={log.clientName || ''}
onChange={(code, name) => onClientCodeChange(index, code, name)}
/>
</td>
<td className="px-3 py-3 text-center">
{log.isManual && (
<div className="flex items-center gap-1 justify-center">
@@ -1687,6 +1886,7 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-3 py-2"></td>
</tr>
))}
@@ -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}

View File

@@ -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만 유지)