feat:계좌 입출금내역 분개(Split) 기능 구현
- BankTransactionSplit 모델 생성 - EaccountController에 splits/saveSplits/deleteSplits 메서드 추가 - 라우트 3개 추가 (GET/POST/DELETE splits) - BankSplitModal React 컴포넌트 추가 - TransactionTable에 분개 컬럼/하위행 렌더링 - App 컴포넌트에 분개 상태 및 핸들러 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) 정렬 상태로 전달됨
|
||||
|
||||
125
app/Models/Barobill/BankTransactionSplit.php
Normal file
125
app/Models/Barobill/BankTransactionSplit.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\Tenants\Tenant;
|
||||
|
||||
/**
|
||||
* 계좌 입출금 거래 분개 모델
|
||||
* 하나의 입출금 거래를 여러 계정과목으로 분개하여 저장
|
||||
*/
|
||||
class BankTransactionSplit extends Model
|
||||
{
|
||||
protected $table = 'barobill_bank_transaction_splits';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'original_unique_key',
|
||||
'split_amount',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'description',
|
||||
'memo',
|
||||
'sort_order',
|
||||
'bank_account_num',
|
||||
'trans_dt',
|
||||
'trans_date',
|
||||
'original_deposit',
|
||||
'original_withdraw',
|
||||
'summary',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'split_amount' => '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();
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-stone-900">거래 분개</h3>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-stone-50 rounded-lg text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-stone-500">적요</span>
|
||||
<span className="font-medium">{log.summary || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-stone-500">거래일시</span>
|
||||
<span className="font-medium">{log.transDateTime || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-stone-500">{isDeposit ? '입금액' : '출금액'}</span>
|
||||
<span className={`font-bold ${isDeposit ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(originalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto" style={ {maxHeight: '400px'} }>
|
||||
<div className="space-y-3">
|
||||
{splits.map((split, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-3 bg-stone-50 rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-stone-500 mb-1">금액 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatAmountInput(split.amount)}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-stone-500 mb-1">계정과목</label>
|
||||
<AccountCodeSelect
|
||||
value={split.accountCode}
|
||||
onChange={(code, name) => updateSplit(index, { accountCode: code, accountName: name })}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-stone-500 mb-1">내역</label>
|
||||
<input
|
||||
type="text"
|
||||
value={split.description || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-stone-500 mb-1">메모</label>
|
||||
<input
|
||||
type="text"
|
||||
value={split.memo || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSplit(index)}
|
||||
disabled={splits.length <= 1}
|
||||
className="mt-6 p-2 text-red-500 hover:bg-red-50 rounded-lg disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addSplit}
|
||||
className="mt-3 w-full py-2 border-2 border-dashed border-stone-300 text-stone-500 rounded-lg hover:border-emerald-400 hover:text-emerald-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
분개 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-stone-200 bg-stone-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-stone-500">분개 합계</span>
|
||||
<span className={`font-bold ${isValid ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(splitTotal)}원
|
||||
{!isValid && (
|
||||
<span className="ml-2 text-xs font-normal">
|
||||
(차이: {formatCurrency(originalAmount - splitTotal)}원)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{existingSplits && existingSplits.length > 0 && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="py-2 px-4 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resetting ? '복구 중...' : '분개 복구'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isValid || saving}
|
||||
className="flex-1 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? '저장 중...' : '분개 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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
|
||||
<table className="w-full text-left text-sm text-stone-600">
|
||||
<thead className="bg-stone-50 text-xs uppercase font-medium text-stone-500 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-2 py-4 bg-stone-50 text-center w-[50px]">분개</th>
|
||||
<th className="px-4 py-4 bg-stone-50">거래일시</th>
|
||||
<th className="px-4 py-4 bg-stone-50">계좌정보</th>
|
||||
<th className="px-4 py-4 bg-stone-50">적요/내용</th>
|
||||
@@ -1370,13 +1610,40 @@ className="flex items-center gap-2 px-4 py-2 bg-stone-100 text-stone-700 rounded
|
||||
<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>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<tr key={index} className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-green-50/30' : ''} ${log.isOverridden ? 'bg-amber-50/50' : ''}`}>
|
||||
logs.map((log, index) => {
|
||||
const logSplits = splits && splits[log.uniqueKey] ? splits[log.uniqueKey] : [];
|
||||
const hasSplits = logSplits.length > 0;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<tr className={`hover:bg-stone-50 transition-colors ${log.isSaved ? 'bg-green-50/30' : ''} ${log.isOverridden ? 'bg-amber-50/50' : ''}`}>
|
||||
<td className="px-2 py-3 text-center">
|
||||
{!hasSplits ? (
|
||||
<button
|
||||
onClick={() => onOpenSplitModal(log, log.uniqueKey)}
|
||||
className="p-1 text-purple-500 hover:bg-purple-50 rounded transition-colors"
|
||||
title="분개 추가"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { if (confirm('분개를 삭제하시겠습니까?')) onDeleteSplits(log.uniqueKey); }}
|
||||
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="분개 삭제"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="font-medium text-stone-900">{log.transDateTime || '-'}</div>
|
||||
{log.isManual && (
|
||||
@@ -1426,13 +1693,24 @@ className="w-full px-2 py-1 text-sm border border-stone-200 rounded focus:ring-2
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<AccountCodeSelect
|
||||
value={log.accountCode}
|
||||
onChange={(code, name) => onAccountCodeChange(index, code, name)}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
{log.accountName && (
|
||||
<div className="text-xs text-emerald-600 mt-1">{log.accountName}</div>
|
||||
{hasSplits ? (
|
||||
<button
|
||||
onClick={() => onOpenSplitModal(log, log.uniqueKey, logSplits)}
|
||||
className="text-sm text-emerald-600 hover:text-emerald-800 hover:underline font-medium"
|
||||
>
|
||||
분개 수정 ({logSplits.length}건)
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<AccountCodeSelect
|
||||
value={log.accountCode}
|
||||
onChange={(code, name) => onAccountCodeChange(index, code, name)}
|
||||
accountCodes={accountCodes}
|
||||
/>
|
||||
{log.accountName && (
|
||||
<div className="text-xs text-emerald-600 mt-1">{log.accountName}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
@@ -1460,7 +1738,39 @@ className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
{/* 분개 하위 행 */}
|
||||
{hasSplits && logSplits.map((split, sIdx) => (
|
||||
<tr key={`${index}-split-${sIdx}`} className="bg-amber-50/30">
|
||||
<td className="px-2 py-2 text-center">
|
||||
<svg className="w-4 h-4 text-amber-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-stone-400" colSpan="2">
|
||||
<span className="text-amber-600 font-medium">분개 #{sIdx + 1}</span>
|
||||
{split.memo && <span className="ml-2 text-stone-400">- {split.memo}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-stone-500">{split.description || '-'}</td>
|
||||
<td className="px-4 py-2 text-right text-sm font-medium text-blue-600">
|
||||
{log.deposit > 0 ? new Intl.NumberFormat('ko-KR').format(split.split_amount || 0) + '원' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-sm font-medium text-red-600">
|
||||
{log.withdraw > 0 ? new Intl.NumberFormat('ko-KR').format(split.split_amount || 0) + '원' : '-'}
|
||||
</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-4 py-2 text-xs">
|
||||
{split.account_name && (
|
||||
<span className="text-emerald-600">{split.account_code} {split.account_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2"></td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 */}
|
||||
<BankSplitModal
|
||||
isOpen={splitModalOpen}
|
||||
onClose={() => { setSplitModalOpen(false); setSplitModalLog(null); setSplitModalExisting([]); }}
|
||||
log={splitModalLog}
|
||||
accountCodes={accountCodes}
|
||||
onSave={handleSaveSplits}
|
||||
onReset={handleResetSplits}
|
||||
splits={splitModalExisting}
|
||||
/>
|
||||
|
||||
{/* Transaction Edit Modal */}
|
||||
<TransactionEditModal
|
||||
isOpen={editModalOpen}
|
||||
|
||||
@@ -473,6 +473,10 @@
|
||||
Route::post('/manual', [\App\Http\Controllers\Barobill\EaccountController::class, 'storeManual'])->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만 유지)
|
||||
|
||||
Reference in New Issue
Block a user