diff --git a/app/Http/Controllers/Barobill/EaccountController.php b/app/Http/Controllers/Barobill/EaccountController.php index 266d5102..9c75a735 100644 --- a/app/Http/Controllers/Barobill/EaccountController.php +++ b/app/Http/Controllers/Barobill/EaccountController.php @@ -8,6 +8,7 @@ use App\Models\Barobill\BarobillMember; use App\Models\Barobill\BankTransaction; use App\Models\Barobill\BankTransactionOverride; +use App\Models\Barobill\BankTransactionSplit; use App\Models\Tenants\Tenant; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -1286,6 +1287,115 @@ public function destroyManual(int $id): JsonResponse } } + /** + * 분개 내역 조회 + */ + public function splits(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $startDate = $request->input('startDate', date('Ymd')); + $endDate = $request->input('endDate', date('Ymd')); + + $splits = BankTransactionSplit::getByDateRange($tenantId, $startDate, $endDate); + + return response()->json([ + 'success' => true, + 'data' => $splits + ]); + } catch (\Throwable $e) { + Log::error('계좌 분개 내역 조회 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '조회 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 분개 저장 + */ + public function saveSplits(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $uniqueKey = $request->input('uniqueKey'); + $originalData = $request->input('originalData', []); + $splits = $request->input('splits', []); + + if (empty($uniqueKey)) { + return response()->json([ + 'success' => false, + 'error' => '고유키가 없습니다.' + ]); + } + + // 분개 금액 합계 검증 + $originalAmount = floatval($originalData['originalAmount'] ?? 0); + $splitTotal = array_sum(array_map(function ($s) { + return floatval($s['amount'] ?? 0); + }, $splits)); + + if (abs($originalAmount - $splitTotal) > 0.01) { + return response()->json([ + 'success' => false, + 'error' => "분개 금액 합계({$splitTotal})가 원본 금액({$originalAmount})과 일치하지 않습니다." + ]); + } + + DB::beginTransaction(); + + BankTransactionSplit::saveSplits($tenantId, $uniqueKey, $originalData, $splits); + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => '분개가 저장되었습니다.', + 'splitCount' => count($splits) + ]); + } catch (\Throwable $e) { + DB::rollBack(); + Log::error('계좌 분개 저장 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '저장 오류: ' . $e->getMessage() + ]); + } + } + + /** + * 분개 삭제 (원본으로 복원) + */ + public function deleteSplits(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); + $uniqueKey = $request->input('uniqueKey'); + + if (empty($uniqueKey)) { + return response()->json([ + 'success' => false, + 'error' => '고유키가 없습니다.' + ]); + } + + $deleted = BankTransactionSplit::deleteSplits($tenantId, $uniqueKey); + + return response()->json([ + 'success' => true, + 'message' => '분개가 삭제되었습니다.', + 'deleted' => $deleted + ]); + } catch (\Throwable $e) { + Log::error('계좌 분개 삭제 오류: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'error' => '삭제 오류: ' . $e->getMessage() + ]); + } + } + /** * 병합된 로그에서 수동입력 건의 잔액을 직전 거래 기준으로 재계산 * 로그는 날짜 내림차순(DESC) 정렬 상태로 전달됨 diff --git a/app/Models/Barobill/BankTransactionSplit.php b/app/Models/Barobill/BankTransactionSplit.php new file mode 100644 index 00000000..509d68a1 --- /dev/null +++ b/app/Models/Barobill/BankTransactionSplit.php @@ -0,0 +1,125 @@ + 'decimal:2', + 'original_deposit' => 'decimal:2', + 'original_withdraw' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 테넌트별 분개 내역 조회 (기간별) + * 고유키를 기준으로 그룹핑하여 반환 + */ + public static function getByDateRange(int $tenantId, string $startDate, string $endDate): array + { + $splits = self::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get(); + + // 고유키별로 그룹핑 + $grouped = []; + foreach ($splits as $split) { + $key = $split->original_unique_key; + if (!isset($grouped[$key])) { + $grouped[$key] = []; + } + $grouped[$key][] = $split; + } + + return $grouped; + } + + /** + * 특정 거래의 분개 내역 조회 + */ + public static function getByUniqueKey(int $tenantId, string $uniqueKey): \Illuminate\Database\Eloquent\Collection + { + return self::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } + + /** + * 특정 거래의 분개 내역 저장 (기존 분개 삭제 후 재생성) + */ + public static function saveSplits(int $tenantId, string $uniqueKey, array $originalData, array $splits): void + { + // 기존 분개 삭제 + self::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + + // 새 분개 저장 + foreach ($splits as $index => $split) { + self::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'split_amount' => $split['amount'] ?? 0, + 'account_code' => $split['accountCode'] ?? null, + 'account_name' => $split['accountName'] ?? null, + 'description' => $split['description'] ?? null, + 'memo' => $split['memo'] ?? null, + 'sort_order' => $index, + 'bank_account_num' => $originalData['bankAccountNum'] ?? '', + 'trans_dt' => $originalData['transDt'] ?? '', + 'trans_date' => $originalData['transDate'] ?? '', + 'original_deposit' => $originalData['originalDeposit'] ?? 0, + 'original_withdraw' => $originalData['originalWithdraw'] ?? 0, + 'summary' => $originalData['summary'] ?? '', + ]); + } + } + + /** + * 분개 내역 삭제 (원본으로 복원) + */ + public static function deleteSplits(int $tenantId, string $uniqueKey): int + { + return self::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete(); + } +} diff --git a/resources/views/barobill/eaccount/index.blade.php b/resources/views/barobill/eaccount/index.blade.php index 78a36332..99fc44e0 100644 --- a/resources/views/barobill/eaccount/index.blade.php +++ b/resources/views/barobill/eaccount/index.blade.php @@ -80,6 +80,9 @@ manualStore: '{{ route("barobill.eaccount.manual.store") }}', manualUpdate: '{{ route("barobill.eaccount.manual.update", ":id") }}', manualDestroy: '{{ route("barobill.eaccount.manual.destroy", ":id") }}', + splits: '{{ route("barobill.eaccount.splits") }}', + saveSplits: '{{ route("barobill.eaccount.splits.save") }}', + deleteSplits: '{{ route("barobill.eaccount.splits.delete") }}', }; const CSRF_TOKEN = '{{ csrf_token() }}'; @@ -791,6 +794,239 @@ className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 t ); }; + // BankSplitModal Component - 계좌 입출금 분개 모달 + const BankSplitModal = ({ isOpen, onClose, log, accountCodes, onSave, onReset, splits: existingSplits }) => { + const [splits, setSplits] = useState([]); + const [saving, setSaving] = useState(false); + const [resetting, setResetting] = useState(false); + + useEffect(() => { + if (isOpen && log) { + if (existingSplits && existingSplits.length > 0) { + // 기존 분개 로드 + setSplits(existingSplits.map(s => ({ + amount: parseFloat(s.split_amount || s.amount || 0), + accountCode: s.account_code || s.accountCode || '', + accountName: s.account_name || s.accountName || '', + description: s.description || '', + memo: s.memo || '' + }))); + } else { + // 새 분개: 원본 금액으로 1개 행 생성 + const origAmount = log.deposit > 0 ? log.deposit : log.withdraw; + setSplits([{ + amount: origAmount, + accountCode: log.accountCode || '', + accountName: log.accountName || '', + description: log.summary || '', + memo: '' + }]); + } + } + }, [isOpen, log, existingSplits]); + + if (!isOpen || !log) return null; + + const originalAmount = log.deposit > 0 ? log.deposit : log.withdraw; + const isDeposit = log.deposit > 0; + const splitTotal = splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0); + const isValid = Math.abs(originalAmount - splitTotal) < 0.01; + + const addSplit = () => { + const remaining = originalAmount - splitTotal; + setSplits([...splits, { + amount: remaining > 0 ? remaining : 0, + accountCode: '', + accountName: '', + description: '', + memo: '' + }]); + }; + + const removeSplit = (index) => { + if (splits.length <= 1) return; + setSplits(splits.filter((_, i) => i !== index)); + }; + + const updateSplit = (index, updates) => { + const newSplits = [...splits]; + newSplits[index] = { ...newSplits[index], ...updates }; + setSplits(newSplits); + }; + + const handleSave = async () => { + if (!isValid) { + notify('분개 합계금액이 원본 금액과 일치하지 않습니다.', 'error'); + return; + } + setSaving(true); + await onSave(log, splits); + setSaving(false); + onClose(); + }; + + const handleReset = async () => { + if (!confirm('분개를 삭제하고 원본 거래로 복구하시겠습니까?')) { + return; + } + setResetting(true); + await onReset(log); + setResetting(false); + onClose(); + }; + + const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0); + + const formatAmountInput = (value) => { + if (!value && value !== 0) return ''; + return new Intl.NumberFormat('ko-KR').format(value); + }; + + const parseAmountInput = (value) => { + const cleaned = String(value).replace(/[^0-9.-]/g, ''); + return parseFloat(cleaned) || 0; + }; + + return ( +
+
+
+
+

거래 분개

+ +
+
+
+ 적요 + {log.summary || '-'} +
+
+ 거래일시 + {log.transDateTime || '-'} +
+
+ {isDeposit ? '입금액' : '출금액'} + + {formatCurrency(originalAmount)}원 + +
+
+
+ +
+
+ {splits.map((split, index) => ( +
+
+
+ + updateSplit(index, { amount: parseAmountInput(e.target.value) })} + className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm text-right focus:ring-2 focus:ring-emerald-500 outline-none" + placeholder="0" + /> +
+
+ + updateSplit(index, { accountCode: code, accountName: name })} + accountCodes={accountCodes} + /> +
+
+ + updateSplit(index, { description: e.target.value })} + placeholder="내역" + className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none" + /> +
+
+ + updateSplit(index, { memo: e.target.value })} + placeholder="분개 메모 (선택)" + className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-500 outline-none" + /> +
+
+ +
+ ))} +
+ + +
+ +
+
+ 분개 합계 + + {formatCurrency(splitTotal)}원 + {!isValid && ( + + (차이: {formatCurrency(originalAmount - splitTotal)}원) + + )} + +
+
+ {existingSplits && existingSplits.length > 0 && ( + + )} + + +
+
+
+
+ ); + }; + // ManualEntryModal Component (수동입력 모달) const ManualEntryModal = ({ isOpen, onClose, onSave, editData, accountCodes, accounts, logs }) => { const [form, setForm] = useState({}); @@ -1207,7 +1443,10 @@ className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 t onEditTransaction, onManualNew, onManualEdit, - onManualDelete + onManualDelete, + splits, + onOpenSplitModal, + onDeleteSplits }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원'; @@ -1355,6 +1594,7 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded + @@ -1370,13 +1610,40 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded {logs.length === 0 ? ( - ) : ( - logs.map((log, index) => ( - + logs.map((log, index) => { + const logSplits = splits && splits[log.uniqueKey] ? splits[log.uniqueKey] : []; + const hasSplits = logSplits.length > 0; + return ( + + + - )) + {/* 분개 하위 행 */} + {hasSplits && logSplits.map((split, sIdx) => ( + + + + + + + + + + + + + ))} + + ); + }) )}
분개 거래일시 계좌정보 적요/내용
+ 해당 기간에 조회된 입출금 내역이 없습니다.
+ {!hasSplits ? ( + + ) : ( + + )} +
{log.transDateTime || '-'}
{log.isManual && ( @@ -1426,13 +1693,24 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2 />
- onAccountCodeChange(index, code, name)} - accountCodes={accountCodes} - /> - {log.accountName && ( -
{log.accountName}
+ {hasSplits ? ( + + ) : ( + <> + onAccountCodeChange(index, code, name)} + accountCodes={accountCodes} + /> + {log.accountName && ( +
{log.accountName}
+ )} + )}
@@ -1460,7 +1738,39 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" )}
+ + + + + 분개 #{sIdx + 1} + {split.memo && - {split.memo}} + {split.description || '-'} + {log.deposit > 0 ? new Intl.NumberFormat('ko-KR').format(split.split_amount || 0) + '원' : '-'} + + {log.withdraw > 0 ? new Intl.NumberFormat('ko-KR').format(split.split_amount || 0) + '원' : '-'} + + {split.account_name && ( + {split.account_code} {split.account_name} + )} +
@@ -1488,6 +1798,13 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" const [manualModalOpen, setManualModalOpen] = useState(false); const [manualEditData, setManualEditData] = useState(null); + // 분개 관련 상태 + const [splits, setSplits] = useState({}); + const [splitModalOpen, setSplitModalOpen] = useState(false); + const [splitModalLog, setSplitModalLog] = useState(null); + const [splitModalKey, setSplitModalKey] = useState(''); + const [splitModalExisting, setSplitModalExisting] = useState([]); + // 날짜 필터 상태 (기본: 현재 월) const currentMonth = getMonthDates(0); const [dateFrom, setDateFrom] = useState(currentMonth.from); @@ -1545,6 +1862,8 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" setLogs(data.data?.logs || []); setPagination(data.data?.pagination || {}); setSummary(data.data?.summary || {}); + // 분개 데이터도 함께 로드 + loadSplits(); } else { setError(data.error || '조회 실패'); setLogs([]); @@ -1557,6 +1876,99 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" } }; + // 분개 데이터 로드 + const loadSplits = async () => { + try { + const params = new URLSearchParams({ + startDate: dateFrom.replace(/-/g, ''), + endDate: dateTo.replace(/-/g, ''), + }); + const response = await fetch(`${API.splits}?${params}`); + const data = await response.json(); + if (data.success) { + setSplits(data.data || {}); + } + } catch (err) { + console.error('분개 데이터 로드 오류:', err); + } + }; + + // 분개 모달 열기 + const handleOpenSplitModal = (log, uniqueKey, existingSplits) => { + setSplitModalLog(log); + setSplitModalKey(uniqueKey); + setSplitModalExisting(existingSplits || []); + setSplitModalOpen(true); + }; + + // 분개 저장 + const handleSaveSplits = async (log, splitData) => { + try { + const originalAmount = log.deposit > 0 ? log.deposit : log.withdraw; + const response = await fetch(API.saveSplits, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN, + 'Accept': 'application/json' + }, + body: JSON.stringify({ + uniqueKey: log.uniqueKey, + originalData: { + bankAccountNum: log.bankAccountNum, + transDt: log.transDate + (log.transTime || ''), + transDate: log.transDate, + originalDeposit: log.deposit || 0, + originalWithdraw: log.withdraw || 0, + originalAmount: originalAmount, + summary: log.summary || '', + }, + splits: splitData + }) + }); + + const data = await response.json(); + if (data.success) { + notify(data.message, 'success'); + loadSplits(); + } else { + notify(data.error || '분개 저장 실패', 'error'); + } + } catch (err) { + notify('분개 저장 오류: ' + err.message, 'error'); + } + }; + + // 분개 삭제 + const handleDeleteSplits = async (uniqueKey) => { + try { + const response = await fetch(API.deleteSplits, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN, + 'Accept': 'application/json' + }, + body: JSON.stringify({ uniqueKey }) + }); + + const data = await response.json(); + if (data.success) { + notify(data.message, 'success'); + loadSplits(); + } else { + notify(data.error || '분개 삭제 실패', 'error'); + } + } catch (err) { + notify('분개 삭제 오류: ' + err.message, 'error'); + } + }; + + // 분개 복구 (모달 내 복구 버튼) + const handleResetSplits = async (log) => { + await handleDeleteSplits(log.uniqueKey); + }; + // 계정과목 변경 핸들러 const handleAccountCodeChange = useCallback((index, code, name) => { setLogs(prevLogs => { @@ -1846,9 +2258,23 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors" onManualNew={handleManualNew} onManualEdit={handleManualEdit} onManualDelete={handleManualDelete} + splits={splits} + onOpenSplitModal={handleOpenSplitModal} + onDeleteSplits={handleDeleteSplits} /> )} + {/* Bank Split Modal */} + { setSplitModalOpen(false); setSplitModalLog(null); setSplitModalExisting([]); }} + log={splitModalLog} + accountCodes={accountCodes} + onSave={handleSaveSplits} + onReset={handleResetSplits} + splits={splitModalExisting} + /> + {/* Transaction Edit Modal */} name('manual.store'); Route::put('/manual/{id}', [\App\Http\Controllers\Barobill\EaccountController::class, 'updateManual'])->name('manual.update'); Route::delete('/manual/{id}', [\App\Http\Controllers\Barobill\EaccountController::class, 'destroyManual'])->name('manual.destroy'); + // 분개 관련 + 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'); }); // 카드 사용내역 (재무관리로 이동됨 - 데이터 API만 유지)