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:
@@ -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,
|
||||
|
||||
@@ -32,6 +32,8 @@ class BankTransaction extends Model
|
||||
'trans_office',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'client_code',
|
||||
'client_name',
|
||||
'is_manual',
|
||||
];
|
||||
|
||||
|
||||
31
app/Models/Barobill/Client.php
Normal file
31
app/Models/Barobill/Client.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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만 유지)
|
||||
|
||||
Reference in New Issue
Block a user