diff --git a/app/Http/Controllers/Finance/JournalEntryController.php b/app/Http/Controllers/Finance/JournalEntryController.php index 18dba6d3..fd6fea52 100644 --- a/app/Http/Controllers/Finance/JournalEntryController.php +++ b/app/Http/Controllers/Finance/JournalEntryController.php @@ -5,6 +5,8 @@ use App\Http\Controllers\Controller; use App\Models\Barobill\AccountCode; use App\Models\Barobill\BankTransaction; +use App\Models\Barobill\CardTransaction; +use App\Models\Barobill\CardTransactionHide; use App\Models\Finance\JournalEntry; use App\Models\Finance\JournalEntryLine; use App\Models\Finance\TradingPartner; @@ -861,4 +863,322 @@ public function accountCodeDestroy(int $id): JsonResponse 'message' => '계정과목이 삭제되었습니다.', ]); } + + // ================================================================ + // 카드거래 기반 분개 API + // ================================================================ + + /** + * 카드거래 목록 조회 (DB 직접 조회 + 분개상태 병합) + */ + public function cardTransactions(Request $request): JsonResponse + { + try { + $tenantId = session('selected_tenant_id', 1); + $startDate = $request->input('startDate', date('Ymd')); + $endDate = $request->input('endDate', date('Ymd')); + $cardNum = $request->input('cardNum', ''); + + $query = CardTransaction::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if (! empty($cardNum)) { + $query->where('card_num', $cardNum); + } + + $transactions = $query->orderBy('use_date', 'desc') + ->orderBy('use_time', 'desc') + ->get(); + + // 숨김 처리된 거래 제외 + $hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate); + $hiddenKeysMap = $hiddenKeys->flip(); + + $transactions = $transactions->filter(function ($tx) use ($hiddenKeysMap) { + return ! $hiddenKeysMap->has($tx->unique_key); + }); + + // 로그 데이터 변환 + $logs = []; + foreach ($transactions as $tx) { + $supplyAmount = $tx->modified_supply_amount !== null + ? (int) $tx->modified_supply_amount + : (int) $tx->approval_amount - (int) $tx->tax; + $taxAmount = $tx->modified_tax !== null + ? (int) $tx->modified_tax + : (int) $tx->tax; + + $logs[] = [ + 'uniqueKey' => $tx->unique_key, + 'useDate' => $tx->use_date, + 'useTime' => $tx->use_time, + 'cardNum' => $tx->card_num, + 'cardCompanyName' => $tx->card_company_name, + 'approvalNum' => $tx->approval_num, + 'approvalType' => $tx->approval_type, + 'approvalAmount' => (int) $tx->approval_amount, + 'supplyAmount' => $supplyAmount, + 'taxAmount' => $taxAmount, + 'merchantName' => $tx->merchant_name, + 'merchantBizNum' => $tx->merchant_biz_num, + 'deductionType' => $tx->deduction_type, + 'accountCode' => $tx->account_code, + 'accountName' => $tx->account_name, + 'memo' => $tx->memo, + 'description' => $tx->description, + ]; + } + + // 각 거래의 uniqueKey 수집 + $uniqueKeys = array_column($logs, 'uniqueKey'); + + // 분개 완료된 source_key 조회 + $journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'ecard_transaction', $uniqueKeys); + $journaledKeysMap = array_flip($journaledKeys); + + // 분개된 전표 ID 조회 + $journalMap = []; + if (! empty($journaledKeys)) { + $journals = JournalEntry::where('tenant_id', $tenantId) + ->where('source_type', 'ecard_transaction') + ->whereIn('source_key', $journaledKeys) + ->select('id', 'source_key', 'entry_no') + ->get(); + foreach ($journals as $j) { + $journalMap[$j->source_key] = ['id' => $j->id, 'entry_no' => $j->entry_no]; + } + } + + // 각 거래에 분개 상태 추가 + foreach ($logs as &$log) { + $key = $log['uniqueKey'] ?? ''; + $log['hasJournal'] = isset($journaledKeysMap[$key]); + $log['journalId'] = $journalMap[$key]['id'] ?? null; + $log['journalEntryNo'] = $journalMap[$key]['entry_no'] ?? null; + } + unset($log); + + // 통계 + $totalCount = count($logs); + $totalAmount = array_sum(array_column($logs, 'approvalAmount')); + $deductibleSum = 0; + $nonDeductibleSum = 0; + foreach ($logs as $log) { + if ($log['deductionType'] === 'non_deductible') { + $nonDeductibleSum += $log['approvalAmount']; + } else { + $deductibleSum += $log['approvalAmount']; + } + } + $journaledCount = count($journaledKeys); + + // 카드 목록 (드롭다운용) + $cards = CardTransaction::where('tenant_id', $tenantId) + ->select('card_num', 'card_company_name') + ->distinct() + ->get() + ->toArray(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'logs' => $logs, + 'cards' => $cards, + 'summary' => [ + 'totalCount' => $totalCount, + 'totalAmount' => $totalAmount, + 'deductibleSum' => $deductibleSum, + 'nonDeductibleSum' => $nonDeductibleSum, + ], + 'journalStats' => [ + 'journaledCount' => $journaledCount, + 'unjournaledCount' => $totalCount - $journaledCount, + ], + ], + ]); + } catch (\Throwable $e) { + Log::error('카드거래 목록 조회 오류: '.$e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => '카드거래 목록 조회 실패: '.$e->getMessage(), + ], 500); + } + } + + /** + * 카드거래 기반 전표 생성 + */ + public function storeFromCard(Request $request): JsonResponse + { + $request->validate([ + 'source_key' => 'required|string|max:255', + 'entry_date' => 'required|date', + 'description' => 'nullable|string|max:500', + 'lines' => 'required|array|min:2', + 'lines.*.dc_type' => 'required|in:debit,credit', + 'lines.*.account_code' => 'required|string|max:10', + 'lines.*.account_name' => 'required|string|max:100', + 'lines.*.trading_partner_id' => 'nullable|integer', + 'lines.*.trading_partner_name' => 'nullable|string|max:100', + 'lines.*.debit_amount' => 'required|integer|min:0', + 'lines.*.credit_amount' => 'required|integer|min:0', + 'lines.*.description' => 'nullable|string|max:300', + ]); + + $tenantId = session('selected_tenant_id', 1); + $lines = $request->lines; + + $totalDebit = collect($lines)->sum('debit_amount'); + $totalCredit = collect($lines)->sum('credit_amount'); + + if ($totalDebit !== $totalCredit || $totalDebit === 0) { + return response()->json([ + 'success' => false, + 'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.', + ], 422); + } + + // 중복 분개 체크 + $existing = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $request->source_key); + if ($existing) { + return response()->json([ + 'success' => false, + 'message' => '이미 분개가 완료된 거래입니다. (전표번호: '.$existing->entry_no.')', + ], 422); + } + + $maxRetries = 3; + $lastError = null; + + for ($attempt = 0; $attempt < $maxRetries; $attempt++) { + try { + $entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) { + $entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date); + + $entry = JournalEntry::create([ + 'tenant_id' => $tenantId, + 'entry_no' => $entryNo, + 'entry_date' => $request->entry_date, + 'description' => $request->description, + 'total_debit' => $totalDebit, + 'total_credit' => $totalCredit, + 'status' => 'draft', + 'source_type' => 'ecard_transaction', + 'source_key' => $request->source_key, + 'created_by_name' => auth()->user()?->name ?? '시스템', + ]); + + foreach ($lines as $i => $line) { + JournalEntryLine::create([ + 'tenant_id' => $tenantId, + 'journal_entry_id' => $entry->id, + 'line_no' => $i + 1, + 'dc_type' => $line['dc_type'], + 'account_code' => $line['account_code'], + 'account_name' => $line['account_name'], + 'trading_partner_id' => $line['trading_partner_id'] ?? null, + 'trading_partner_name' => $line['trading_partner_name'] ?? null, + 'debit_amount' => $line['debit_amount'], + 'credit_amount' => $line['credit_amount'], + 'description' => $line['description'] ?? null, + ]); + } + + return $entry; + }); + + return response()->json([ + 'success' => true, + 'message' => '분개가 저장되었습니다.', + 'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no], + ]); + } catch (\Illuminate\Database\QueryException $e) { + $lastError = $e; + if ($e->errorInfo[1] === 1062) { + continue; + } + break; + } catch (\Throwable $e) { + $lastError = $e; + break; + } + } + + Log::error('카드거래 분개 저장 오류: '.$lastError->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => '분개 저장 실패: '.$lastError->getMessage(), + ], 500); + } + + /** + * 특정 카드거래의 기존 분개 조회 + */ + public function cardJournals(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $sourceKey = $request->get('source_key'); + + if (! $sourceKey) { + return response()->json(['success' => false, 'message' => 'source_key가 필요합니다.'], 422); + } + + $entry = JournalEntry::forTenant($tenantId) + ->where('source_type', 'ecard_transaction') + ->where('source_key', $sourceKey) + ->with('lines') + ->first(); + + if (! $entry) { + return response()->json(['success' => true, 'data' => null]); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $entry->id, + 'entry_no' => $entry->entry_no, + 'entry_date' => $entry->entry_date->format('Y-m-d'), + 'description' => $entry->description, + 'total_debit' => $entry->total_debit, + 'total_credit' => $entry->total_credit, + 'status' => $entry->status, + 'lines' => $entry->lines->map(function ($line) { + return [ + 'id' => $line->id, + 'line_no' => $line->line_no, + 'dc_type' => $line->dc_type, + 'account_code' => $line->account_code, + 'account_name' => $line->account_name, + 'trading_partner_id' => $line->trading_partner_id, + 'trading_partner_name' => $line->trading_partner_name, + 'debit_amount' => $line->debit_amount, + 'credit_amount' => $line->credit_amount, + 'description' => $line->description, + ]; + }), + ], + ]); + } + + /** + * 카드거래 분개 삭제 (soft delete) + */ + public function deleteCardJournal(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + + $entry = JournalEntry::forTenant($tenantId) + ->where('source_type', 'ecard_transaction') + ->findOrFail($id); + + $entry->delete(); + + return response()->json([ + 'success' => true, + 'message' => '분개가 삭제되었습니다.', + ]); + } } diff --git a/resources/views/finance/journal-entries.blade.php b/resources/views/finance/journal-entries.blade.php index b510bcb0..e8aefaf3 100644 --- a/resources/views/finance/journal-entries.blade.php +++ b/resources/views/finance/journal-entries.blade.php @@ -57,6 +57,7 @@ const RefreshCw = createIcon('refresh-cw'); const Settings = createIcon('settings'); const Calendar = createIcon('calendar'); +const CreditCard = createIcon('credit-card'); const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); @@ -898,21 +899,29 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f // ============================================================ const JournalEntryList = ({ accountCodes, tradingPartners, onPartnerAdded, refreshKey, onEdit }) => { const [transactions, setTransactions] = useState([]); + const [cardTransactionsList, setCardTransactionsList] = useState([]); const [journalEntries, setJournalEntries] = useState([]); const [loading, setLoading] = useState(false); const [accounts, setAccounts] = useState([]); + const [cards, setCards] = useState([]); const [journalStats, setJournalStats] = useState({}); + const [cardJournalStats, setCardJournalStats] = useState({}); const [dateRange, setDateRange] = useState({ start: getKoreanDate(-30).replace(/-/g, ''), end: getKoreanDate().replace(/-/g, ''), }); const [selectedAccount, setSelectedAccount] = useState(''); - const [viewFilter, setViewFilter] = useState('all'); // all, bank, manual, unjournaled + const [selectedCard, setSelectedCard] = useState(''); + const [viewFilter, setViewFilter] = useState('all'); // all, bank, card, manual, unjournaled // 분개 모달 상태 (은행거래 → 전표 생성) const [showJournalModal, setShowJournalModal] = useState(false); const [modalTransaction, setModalTransaction] = useState(null); + // 카드 분개 모달 상태 + const [showCardJournalModal, setShowCardJournalModal] = useState(false); + const [cardModalTransaction, setCardModalTransaction] = useState(null); + const setMonthRange = (offset) => { const now = new Date(); const target = new Date(now.getFullYear(), now.getMonth() + offset, 1); @@ -928,16 +937,20 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f try { const bankParams = new URLSearchParams({ startDate: dateRange.start, endDate: dateRange.end }); if (selectedAccount) bankParams.set('accountNum', selectedAccount); + const cardParams = new URLSearchParams({ startDate: dateRange.start, endDate: dateRange.end }); + if (selectedCard) cardParams.set('cardNum', selectedCard); const journalParams = new URLSearchParams({ start_date: formatDate(dateRange.start), end_date: formatDate(dateRange.end), }); - const [bankRes, journalRes] = await Promise.all([ + const [bankRes, cardRes, journalRes] = await Promise.all([ fetch(`/finance/journal-entries/bank-transactions?${bankParams}`), + fetch(`/finance/journal-entries/card-transactions?${cardParams}`), fetch(`/finance/journal-entries/list?${journalParams}`), ]); const bankData = await bankRes.json(); + const cardData = await cardRes.json(); const journalData = await journalRes.json(); if (bankData.success) { @@ -945,6 +958,11 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f setJournalStats(bankData.data?.journalStats || {}); if (bankData.data?.accounts) setAccounts(bankData.data.accounts); } + if (cardData.success) { + setCardTransactionsList(cardData.data?.logs || []); + setCardJournalStats(cardData.data?.journalStats || {}); + if (cardData.data?.cards) setCards(cardData.data.cards); + } if (journalData.success) { setJournalEntries(journalData.data || []); } @@ -963,13 +981,13 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f journalEntries.forEach(je => { journalMap[je.id] = je; }); // 통합 행 생성 - const bankLinkedJournalIds = new Set(); + const linkedJournalIds = new Set(); const rows = []; // 1) 은행거래 행 transactions.forEach(tx => { const je = tx.journalId ? journalMap[tx.journalId] : null; - if (tx.journalId) bankLinkedJournalIds.add(tx.journalId); + if (tx.journalId) linkedJournalIds.add(tx.journalId); rows.push({ type: 'bank', key: `bank-${tx.uniqueKey}`, @@ -986,14 +1004,40 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f totalDebit: je ? je.total_debit : 0, totalCredit: je ? je.total_credit : 0, bankTx: tx, + cardTx: null, sortKey: tx.transDate + (tx.transTime || '000000'), }); }); - // 2) 수동 전표 행 (은행거래에 연결되지 않은 것만) + // 2) 카드거래 행 + cardTransactionsList.forEach(ctx => { + const je = ctx.journalId ? journalMap[ctx.journalId] : null; + if (ctx.journalId) linkedJournalIds.add(ctx.journalId); + rows.push({ + type: 'card', + key: `card-${ctx.uniqueKey}`, + date: ctx.useDate, + time: ctx.useTime, + description: ctx.merchantName || '-', + deposit: 0, + withdraw: ctx.approvalAmount || 0, + balance: null, + hasJournal: ctx.hasJournal, + journalId: ctx.journalId, + entryNo: ctx.journalEntryNo || (je && je.entry_no) || null, + lines: je ? je.lines : [], + totalDebit: je ? je.total_debit : 0, + totalCredit: je ? je.total_credit : 0, + bankTx: null, + cardTx: ctx, + sortKey: ctx.useDate + (ctx.useTime || '000000'), + }); + }); + + // 3) 수동 전표 행 (은행/카드거래에 연결되지 않은 것만) journalEntries.forEach(je => { - if (bankLinkedJournalIds.has(je.id)) return; - if (je.source_type === 'bank_transaction') return; + if (linkedJournalIds.has(je.id)) return; + if (je.source_type === 'bank_transaction' || je.source_type === 'ecard_transaction') return; rows.push({ type: 'manual', key: `manual-${je.id}`, @@ -1010,6 +1054,7 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f totalDebit: je.total_debit, totalCredit: je.total_credit, bankTx: null, + cardTx: null, sortKey: je.entry_date.replace(/-/g, '') + '999999', }); }); @@ -1020,24 +1065,34 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f // 필터 적용 const filtered = rows.filter(r => { if (viewFilter === 'bank') return r.type === 'bank'; + if (viewFilter === 'card') return r.type === 'card'; if (viewFilter === 'manual') return r.type === 'manual'; - if (viewFilter === 'unjournaled') return r.type === 'bank' && !r.hasJournal; + if (viewFilter === 'unjournaled') return (r.type === 'bank' || r.type === 'card') && !r.hasJournal; return true; }); // 통계 const bankRows = rows.filter(r => r.type === 'bank'); + const cardRows = rows.filter(r => r.type === 'card'); const manualRows = rows.filter(r => r.type === 'manual'); const depositSum = bankRows.reduce((s, r) => s + r.deposit, 0); const withdrawSum = bankRows.reduce((s, r) => s + r.withdraw, 0); + const cardAmountSum = cardRows.reduce((s, r) => s + r.withdraw, 0); const totalDebit = filtered.reduce((s, r) => s + (r.totalDebit || 0), 0); const totalCredit = filtered.reduce((s, r) => s + (r.totalCredit || 0), 0); + const allJournaledCount = (journalStats.journaledCount || 0) + (cardJournalStats.journaledCount || 0); + const allUnjournaledCount = (journalStats.unjournaledCount || 0) + (cardJournalStats.unjournaledCount || 0); const handleJournal = (tx) => { setModalTransaction(tx); setShowJournalModal(true); }; + const handleCardJournal = (ctx) => { + setCardModalTransaction(ctx); + setShowCardJournalModal(true); + }; + const handleJournalSaved = () => { setShowJournalModal(false); setModalTransaction(null); @@ -1050,6 +1105,18 @@ className="px-2.5 py-1 text-xs font-medium bg-amber-100 text-amber-700 rounded-f fetchData(); }; + const handleCardJournalSaved = () => { + setShowCardJournalModal(false); + setCardModalTransaction(null); + fetchData(); + }; + + const handleCardJournalDeleted = () => { + setShowCardJournalModal(false); + setCardModalTransaction(null); + fetchData(); + }; + return (
{/* 필터 바 */} @@ -1085,13 +1152,24 @@ className="px-3 py-1.5 text-sm border border-stone-200 rounded-lg focus:ring-2 f ))} + +
{[ ['all', '전체', rows.length], ['bank', '은행거래', bankRows.length], + ['card', '카드거래', cardRows.length], ['manual', '수동전표', manualRows.length], - ['unjournaled', '미분개', bankRows.filter(r => !r.hasJournal).length], + ['unjournaled', '미분개', rows.filter(r => (r.type === 'bank' || r.type === 'card') && !r.hasJournal).length], ].map(([val, label, count]) => (
{/* 통계 카드 */} -
+
@@ -1133,12 +1211,21 @@ className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${vie
+
+
+
+
+

카드사용

+

{formatCurrency(cardAmountSum)}

+
+
+

분개완료

-

{journalStats.journaledCount || 0}건

+

{allJournaledCount}건

@@ -1147,7 +1234,7 @@ className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${vie

미분개

-

{journalStats.unjournaledCount || 0}건

+

{allUnjournaledCount}건

@@ -1183,15 +1270,21 @@ className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${vie {filtered.map(row => ( - +
{formatDate(row.date)}
{row.time &&
{formatTime(row.time)}
}
- + + {row.type === 'card' && } {row.description} + {row.type === 'card' && row.cardTx && ( + + {row.cardTx.deductionType === 'non_deductible' ? '불공제' : '공제'} + + )}
@@ -1227,13 +1320,13 @@ className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${vie {row.totalCredit > 0 ? formatCurrency(row.totalCredit) : ''} - {row.type === 'bank' && !row.hasJournal ? ( - ) : row.hasJournal ? ( - @@ -1271,6 +1364,19 @@ className="p-1 text-stone-300 group-hover:text-emerald-500 hover:bg-emerald-50 r onPartnerAdded={onPartnerAdded} /> )} + + {/* 카드 분개 모달 */} + {showCardJournalModal && cardModalTransaction && ( + { setShowCardJournalModal(false); setCardModalTransaction(null); }} + onSaved={handleCardJournalSaved} + onDeleted={handleCardJournalDeleted} + onPartnerAdded={onPartnerAdded} + /> + )} ); }; @@ -1638,6 +1744,402 @@ className={`px-6 py-2 text-sm font-medium rounded-lg flex items-center gap-1 tra ); }; +// ============================================================ +// CardJournalEntryModal (카드거래 분개 모달) +// ============================================================ +const CardJournalEntryModal = ({ transaction, accountCodes, tradingPartners, onClose, onSaved, onDeleted, onPartnerAdded }) => { + const ctx = transaction; + const amount = ctx.approvalAmount || 0; + const supplyAmount = ctx.supplyAmount || 0; + const taxAmount = ctx.taxAmount || 0; + const isDeductible = ctx.deductionType !== 'non_deductible'; + + const [saving, setSaving] = useState(false); + const [loadingJournal, setLoadingJournal] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [existingJournalId, setExistingJournalId] = useState(null); + const [description, setDescription] = useState(ctx.merchantName || ''); + const [showAddPartnerModal, setShowAddPartnerModal] = useState(false); + const [addPartnerLineIndex, setAddPartnerLineIndex] = useState(null); + + // 기본 분개 라인 생성 + const getDefaultLines = () => { + const expenseCode = ctx.accountCode || '826'; + const expenseName = ctx.accountName || '잡비'; + + if (isDeductible) { + // 공제: 차변(비용=공급가) + 차변(부가세대급금=세액) / 대변(미지급금=합계) + return [ + { key: 1, dc_type: 'debit', account_code: expenseCode, account_name: expenseName, trading_partner_id: null, trading_partner_name: '', debit_amount: supplyAmount, credit_amount: 0, description: '' }, + { key: 2, dc_type: 'debit', account_code: '135', account_name: '부가세대급금', trading_partner_id: null, trading_partner_name: '', debit_amount: taxAmount, credit_amount: 0, description: '' }, + { key: 3, dc_type: 'credit', account_code: '253', account_name: '미지급금', trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: amount, description: '' }, + ]; + } else { + // 불공제: 차변(비용=합계) / 대변(미지급금=합계) + return [ + { key: 1, dc_type: 'debit', account_code: expenseCode, account_name: expenseName, trading_partner_id: null, trading_partner_name: '', debit_amount: amount, credit_amount: 0, description: '' }, + { key: 2, dc_type: 'credit', account_code: '253', account_name: '미지급금', trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: amount, description: '' }, + ]; + } + }; + + const [lines, setLines] = useState(getDefaultLines()); + + // 기존 분개 로드 + useEffect(() => { + if (ctx.hasJournal) { + setLoadingJournal(true); + fetch(`/finance/journal-entries/card-journals?source_key=${encodeURIComponent(ctx.uniqueKey)}`) + .then(r => r.json()) + .then(data => { + if (data.success && data.data) { + setIsEditMode(true); + setExistingJournalId(data.data.id); + setDescription(data.data.description || ''); + if (data.data.lines && data.data.lines.length > 0) { + setLines(data.data.lines.map((l, i) => ({ ...l, key: l.id || Date.now() + i }))); + } + } + }) + .catch(err => console.error('기존 분개 로드 오류:', err)) + .finally(() => setLoadingJournal(false)); + } + }, []); + + const totalDebit = lines.reduce((sum, l) => sum + (parseInt(l.debit_amount) || 0), 0); + const totalCredit = lines.reduce((sum, l) => sum + (parseInt(l.credit_amount) || 0), 0); + const isBalanced = totalDebit === totalCredit && totalDebit > 0; + const difference = totalDebit - totalCredit; + + const updateLine = (index, field, value) => { + const updated = [...lines]; + updated[index] = { ...updated[index], [field]: value }; + if (field === 'dc_type') { + if (value === 'debit') { updated[index].credit_amount = 0; } + else { updated[index].debit_amount = 0; } + } + setLines(updated); + }; + + const toggleDcType = (index) => { + setLines(prev => prev.map((l, i) => { + if (i !== index) return l; + const newType = l.dc_type === 'debit' ? 'credit' : 'debit'; + return { ...l, dc_type: newType, debit_amount: l.credit_amount, credit_amount: l.debit_amount }; + })); + }; + + const addLine = () => setLines([...lines, { + key: Date.now() + Math.random(), dc_type: 'debit', account_code: '', account_name: '', + trading_partner_id: null, trading_partner_name: '', debit_amount: 0, credit_amount: 0, description: '', + }]); + + const removeLine = (index) => { + if (lines.length <= 2) return; + setLines(lines.filter((_, i) => i !== index)); + }; + + const entryDate = ctx.useDate ? `${ctx.useDate.slice(0,4)}-${ctx.useDate.slice(4,6)}-${ctx.useDate.slice(6,8)}` : getKoreanDate(); + + const handleSave = async () => { + const emptyLine = lines.find(l => !l.account_code); + if (emptyLine) { notify('모든 분개 라인의 계정과목을 선택해주세요.', 'warning'); return; } + if (!isBalanced) { notify('차변과 대변의 합계가 일치하지 않습니다.', 'warning'); return; } + + setSaving(true); + try { + if (isEditMode && existingJournalId) { + await fetch(`/finance/journal-entries/card-journal/${existingJournalId}`, { + method: 'DELETE', headers: { 'X-CSRF-TOKEN': CSRF_TOKEN }, + }); + } + + const payload = { + source_key: ctx.uniqueKey, + entry_date: entryDate, + description: description, + lines: lines.map(l => ({ + dc_type: l.dc_type, account_code: l.account_code, account_name: l.account_name, + trading_partner_id: l.trading_partner_id, trading_partner_name: l.trading_partner_name, + debit_amount: parseInt(l.debit_amount) || 0, credit_amount: parseInt(l.credit_amount) || 0, + description: l.description, + })), + }; + + const res = await fetch('/finance/journal-entries/store-from-card', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': CSRF_TOKEN }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const text = await res.text(); + try { + const errData = JSON.parse(text); + const msg = errData.message || (errData.errors ? Object.values(errData.errors).flat().join(', ') : `저장 실패 (${res.status})`); + notify(msg, 'error'); + } catch { notify(`저장 실패: 서버 오류 (${res.status})`, 'error'); } + return; + } + + const data = await res.json(); + if (data.success) { notify(data.message || '분개가 저장되었습니다.', 'success'); onSaved(); } + else { notify(data.message || '분개 저장 실패', 'error'); } + } catch (err) { + console.error('카드 분개 저장 오류:', err); + notify('분개 저장 중 오류가 발생했습니다: ' + err.message, 'error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!existingJournalId || !confirm('분개를 삭제하시겠습니까?')) return; + setSaving(true); + try { + const res = await fetch(`/finance/journal-entries/card-journal/${existingJournalId}`, { + method: 'DELETE', headers: { 'X-CSRF-TOKEN': CSRF_TOKEN }, + }); + const data = await res.json(); + if (data.success) { notify('분개가 삭제되었습니다.', 'success'); onDeleted(); } + } catch (err) { + notify('삭제 중 오류가 발생했습니다.', 'error'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {/* 헤더 */} +
+

+ + {isEditMode ? '카드 분개 수정' : '카드 분개 생성'} +

+ +
+ +
+ {/* 카드거래 정보 카드 */} +
+

카드거래 정보

+
+
+ 날짜: + {formatDate(ctx.useDate)} +
+
+ 카드: + {ctx.cardCompanyName} ...{(ctx.cardNum || '').slice(-4)} +
+
+ 가맹점: + {ctx.merchantName || '-'} +
+
+ 승인금액: + {formatCurrency(amount)}원 +
+
+
+
+ 공급가액: + {formatCurrency(supplyAmount)}원 +
+
+ 부가세: + {formatCurrency(taxAmount)}원 +
+
+ 공제: + + {isDeductible ? '공제' : '불공제'} + +
+ {ctx.approvalNum && ( +
+ 승인번호: + {ctx.approvalNum} +
+ )} +
+
+ + {/* 전표 적요 */} +
+ + setDescription(e.target.value)} + placeholder="전표 적요를 입력하세요" + className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:ring-2 focus:ring-emerald-500 outline-none" /> +
+ + {loadingJournal ? ( +
+
+ 분개 데이터 로딩중... +
+ ) : ( +
+

분개 내역

+
+ + + + + + + + + + + + + + {lines.map((line, index) => ( + + + + + + + + + + ))} + + + + + + + + + +
구분계정과목거래처차변대변적요
+ + + document.getElementById(`c-partner-${index}`)?.focus()} + onChange={(code, name) => { + const updated = [...lines]; + updated[index] = { ...updated[index], account_code: code, account_name: name }; + setLines(updated); + }} /> + + document.getElementById(`c-amount-${line.dc_type}-${index}`)?.focus()} + onChange={(id, name) => { + const updated = [...lines]; + updated[index] = { ...updated[index], trading_partner_id: id, trading_partner_name: name }; + setLines(updated); + }} + onAddPartner={() => { setAddPartnerLineIndex(index); setShowAddPartnerModal(true); }} /> + + updateLine(index, 'debit_amount', parseInputCurrency(e.target.value))} + onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); document.getElementById(`c-desc-${index}`)?.focus(); } }} + disabled={line.dc_type !== 'debit'} + placeholder={line.dc_type === 'debit' ? '금액' : ''} + className={`w-full px-2 py-1.5 text-xs text-right border rounded outline-none font-medium ${line.dc_type === 'debit' ? 'border-stone-200 focus:ring-2 focus:ring-blue-500 text-blue-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`} /> + + updateLine(index, 'credit_amount', parseInputCurrency(e.target.value))} + onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); document.getElementById(`c-desc-${index}`)?.focus(); } }} + disabled={line.dc_type !== 'credit'} + placeholder={line.dc_type === 'credit' ? '금액' : ''} + className={`w-full px-2 py-1.5 text-xs text-right border rounded outline-none font-medium ${line.dc_type === 'credit' ? 'border-stone-200 focus:ring-2 focus:ring-red-500 text-red-700' : 'bg-stone-100 border-stone-100 text-stone-300'}`} /> + + updateLine(index, 'description', e.target.value)} + onKeyDown={(e) => { if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); const next = document.getElementById(`c-account-${index + 1}`); if (next) next.focus(); } }} + placeholder="적요" + className="w-full px-2 py-1.5 text-xs border border-stone-200 rounded focus:ring-2 focus:ring-emerald-500 outline-none" /> + + +
+ + + 차변 +

{formatCurrency(totalDebit)}

+
+ 대변 +

{formatCurrency(totalCredit)}

+
+ {difference !== 0 ? ( +
+ 차이: {formatCurrency(Math.abs(difference))} +
+ ) : totalDebit > 0 ? ( +
+ 대차 균형 +
+ ) : null} +
+
+
+ )} +
+ + {/* 하단 버튼 */} +
+
+ {isEditMode && ( + + )} +
+
+ + +
+
+
+ + {/* 거래처 추가 모달 */} + { setShowAddPartnerModal(false); setAddPartnerLineIndex(null); }} + onSaved={(newPartner) => { + if (onPartnerAdded) onPartnerAdded(newPartner); + if (addPartnerLineIndex !== null) { + const updated = [...lines]; + updated[addPartnerLineIndex] = { + ...updated[addPartnerLineIndex], + trading_partner_id: newPartner.id, + trading_partner_name: newPartner.name, + }; + setLines(updated); + } + setAddPartnerLineIndex(null); + }} + /> +
+ ); +}; + // ============================================================ // JournalEntryModal (은행거래 분개 모달) // ============================================================ @@ -2113,7 +2615,7 @@ function App() {

일반전표입력

-

계좌입출금내역을 기반으로 분개 전표를 생성합니다

+

계좌입출금 및 카드사용내역을 기반으로 분개 전표를 생성합니다