feat:일일자금일보 기간별 보고서 기능 추가 (바로빌 계좌내역 기반)

This commit is contained in:
김보곤
2026-02-06 08:45:11 +09:00
parent fb44727633
commit 33ad29ea01
3 changed files with 335 additions and 470 deletions

View File

@@ -5,8 +5,10 @@
use App\Http\Controllers\Controller;
use App\Models\Finance\DailyFundTransaction;
use App\Models\Finance\DailyFundMemo;
use App\Models\Barobill\BankTransaction as BarobillBankTransaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DailyFundController extends Controller
{
@@ -157,4 +159,116 @@ public function saveMemo(Request $request): JsonResponse
],
]);
}
/**
* 기간별 자금일보 (바로빌 계좌 거래내역 기반)
*/
public function periodReport(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$startDate = $request->input('start_date', now()->subMonth()->format('Ymd'));
$endDate = $request->input('end_date', now()->format('Ymd'));
// YYYYMMDD 형식으로 변환
$startDateYmd = str_replace('-', '', $startDate);
$endDateYmd = str_replace('-', '', $endDate);
// 기간 내 거래내역 조회
$transactions = BarobillBankTransaction::where('tenant_id', $tenantId)
->whereBetween('trans_date', [$startDateYmd, $endDateYmd])
->orderBy('trans_date', 'desc')
->orderBy('trans_time', 'desc')
->get();
// 일별로 그룹핑
$dailyData = [];
$accountBalances = []; // 계좌별 최신 잔액 추적
foreach ($transactions as $tx) {
$date = $tx->trans_date;
$accountNum = $tx->bank_account_num;
if (!isset($dailyData[$date])) {
$dailyData[$date] = [
'date' => $date,
'dateFormatted' => $this->formatDateKorean($date),
'accounts' => [],
'deposits' => [],
'withdrawals' => [],
'totalDeposit' => 0,
'totalWithdraw' => 0,
];
}
// 계좌별 데이터 집계
if (!isset($dailyData[$date]['accounts'][$accountNum])) {
$dailyData[$date]['accounts'][$accountNum] = [
'bankName' => $tx->bank_name,
'accountNum' => $accountNum,
'deposit' => 0,
'withdraw' => 0,
'balance' => $tx->balance,
];
}
// 입출금 내역 추가
if ($tx->deposit > 0) {
$dailyData[$date]['deposits'][] = [
'time' => $tx->trans_time,
'bankName' => $tx->bank_name,
'summary' => $tx->summary,
'cast' => $tx->cast,
'amount' => $tx->deposit,
'balance' => $tx->balance,
];
$dailyData[$date]['accounts'][$accountNum]['deposit'] += $tx->deposit;
$dailyData[$date]['totalDeposit'] += $tx->deposit;
}
if ($tx->withdraw > 0) {
$dailyData[$date]['withdrawals'][] = [
'time' => $tx->trans_time,
'bankName' => $tx->bank_name,
'summary' => $tx->summary,
'cast' => $tx->cast,
'amount' => $tx->withdraw,
'balance' => $tx->balance,
];
$dailyData[$date]['accounts'][$accountNum]['withdraw'] += $tx->withdraw;
$dailyData[$date]['totalWithdraw'] += $tx->withdraw;
}
// 해당 일자의 최신 잔액 업데이트
$dailyData[$date]['accounts'][$accountNum]['balance'] = $tx->balance;
}
// accounts를 배열로 변환
foreach ($dailyData as $date => &$data) {
$data['accounts'] = array_values($data['accounts']);
}
// 날짜 내림차순 정렬 (최신 일자가 위)
krsort($dailyData);
return response()->json([
'success' => true,
'data' => [
'startDate' => $startDate,
'endDate' => $endDate,
'dailyReports' => array_values($dailyData),
],
]);
}
private function formatDateKorean(string $dateYmd): string
{
$year = substr($dateYmd, 0, 4);
$month = (int) substr($dateYmd, 4, 2);
$day = (int) substr($dateYmd, 6, 2);
$date = \Carbon\Carbon::createFromFormat('Ymd', $dateYmd);
$dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][$date->dayOfWeek];
return "{$year}{$month}{$day}{$dayOfWeek}요일";
}
}

View File

@@ -6,13 +6,8 @@
<style>
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
body { background: white !important; }
}
#daily-fund-root .min-h-screen {
min-height: auto;
background: transparent;
}
</style>
@endpush
@@ -22,92 +17,29 @@
@endsection
@push('scripts')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
<script type="text/babel" data-version="{{ time() }}">
const { useState, useRef, useEffect } = React;
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
svg.setAttribute('class', className);
ref.current.appendChild(svg);
}
}, [className]);
return <span ref={ref} className="inline-flex items-center" {...props} />;
};
const Calendar = createIcon('calendar');
const ChevronLeft = createIcon('chevron-left');
const ChevronRight = createIcon('chevron-right');
const Download = createIcon('download');
const Printer = createIcon('printer');
const Send = createIcon('send');
const Plus = createIcon('plus');
const Trash2 = createIcon('trash-2');
const Edit = createIcon('edit');
const Save = createIcon('save');
const X = createIcon('x');
const Check = createIcon('check');
const Wallet = createIcon('wallet');
const ArrowUpRight = createIcon('arrow-up-right');
const ArrowDownRight = createIcon('arrow-down-right');
const Building = createIcon('building');
const FileSpreadsheet = createIcon('file-spreadsheet');
const TrendingUp = createIcon('trending-up');
const TrendingDown = createIcon('trending-down');
const RefreshCw = createIcon('refresh-cw');
const AlertCircle = createIcon('alert-circle');
const CheckCircle = createIcon('check-circle');
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect } = React;
function DailyFundReport() {
const today = new Date();
const [selectedDate, setSelectedDate] = useState(today.toISOString().split('T')[0]);
const [showSendModal, setShowSendModal] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [addType, setAddType] = useState('income');
const [reportSent, setReportSent] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [editType, setEditType] = useState('income');
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
const [startDate, setStartDate] = useState(monthAgo.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(today.toISOString().split('T')[0]);
const [dailyReports, setDailyReports] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [incomeList, setIncomeList] = useState([]);
const [expenseList, setExpenseList] = useState([]);
const [previousBalance, setPreviousBalance] = useState(0);
const [memo, setMemo] = useState('');
const [author, setAuthor] = useState('');
const [updatedAt, setUpdatedAt] = useState('');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const [newTransaction, setNewTransaction] = useState({
time: '',
accountName: '',
description: '',
amount: '',
category: '',
note: ''
});
const fetchData = async (date) => {
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch(`/finance/daily-fund/list?date=${date || selectedDate}`);
const res = await fetch(`/finance/daily-fund/period-report?start_date=${startDate.replace(/-/g, '')}&end_date=${endDate.replace(/-/g, '')}`);
const data = await res.json();
if (data.success) {
setIncomeList(data.data.income || []);
setExpenseList(data.data.expense || []);
setPreviousBalance(data.data.previousBalance || 0);
setMemo(data.data.memo || '');
setAuthor(data.data.author || '');
setUpdatedAt(data.data.updatedAt || '');
setDailyReports(data.data.dailyReports || []);
}
} catch (err) {
console.error('데이터 조회 실패:', err);
@@ -116,425 +48,243 @@ function DailyFundReport() {
}
};
useEffect(() => { fetchData(selectedDate); }, [selectedDate]);
const moveDate = (days) => {
const date = new Date(selectedDate);
date.setDate(date.getDate() + days);
setSelectedDate(date.toISOString().split('T')[0]);
};
useEffect(() => { fetchData(); }, [startDate, endDate]);
const formatCurrency = (num) => {
if (!num) return '0';
return num.toLocaleString();
};
const formatCurrencyShort = (num) => {
if (!num) return '0';
if (num >= 100000000) return `${(num / 100000000).toFixed(1)}억`;
if (num >= 10000000) return `${(num / 10000000).toFixed(0)}천만`;
if (num >= 10000) return `${(num / 10000).toFixed(0)}만`;
return num.toLocaleString();
};
const formatInputCurrency = (value) => {
if (!value && value !== 0) return '';
const num = String(value).replace(/[^\d]/g, '');
if (!num) return '';
return Number(num).toLocaleString();
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const getDayOfWeek = (dateStr) => {
const days = ['일', '월', '화', '수', '목', '', '토'];
return days[new Date(dateStr).getDay()];
const setThisMonth = () => {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
setStartDate(firstDay.toISOString().split('T')[0]);
setEndDate(now.toISOString().split('T')[0]);
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일 (${getDayOfWeek(dateStr)})`;
};
const totalIncome = incomeList.reduce((sum, item) => sum + item.amount, 0);
const totalExpense = expenseList.reduce((sum, item) => sum + item.amount, 0);
const totalCurrentBalance = previousBalance + totalIncome - totalExpense;
const getCategoryTotals = (items) => {
const totals = {};
items.forEach(item => {
if (!totals[item.category]) totals[item.category] = 0;
totals[item.category] += item.amount;
});
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
};
const handleAddTransaction = async () => {
if (!newTransaction.description || !newTransaction.amount) return;
setSaving(true);
try {
const res = await fetch('/finance/daily-fund/store', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({
transactionDate: selectedDate,
type: addType,
time: newTransaction.time || new Date().toTimeString().slice(0, 5),
accountName: newTransaction.accountName,
description: newTransaction.description,
amount: parseInt(newTransaction.amount) || 0,
category: newTransaction.category,
note: newTransaction.note,
}),
});
if (res.ok) {
setNewTransaction({ time: '', accountName: '', description: '', amount: '', category: '', note: '' });
setShowAddModal(false);
fetchData(selectedDate);
} else {
const data = await res.json();
alert(data.message || '등록에 실패했습니다.');
}
} catch (err) {
alert('등록에 실패했습니다.');
} finally {
setSaving(false);
}
};
const handleDeleteTransaction = async (type, id) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/finance/daily-fund/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken },
});
if (res.ok) fetchData(selectedDate);
} catch (err) {
alert('삭제에 실패했습니다.');
}
};
const handleEditTransaction = (type, item) => {
setEditType(type);
setEditingItem({ ...item, amount: item.amount.toString() });
setShowEditModal(true);
};
const handleSaveEdit = async () => {
if (!editingItem.description || !editingItem.amount) return;
setSaving(true);
try {
const res = await fetch(`/finance/daily-fund/${editingItem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({
type: editType,
time: editingItem.time,
accountName: editingItem.accountName,
description: editingItem.description,
amount: parseInt(editingItem.amount) || 0,
category: editingItem.category,
note: editingItem.note,
}),
});
if (res.ok) {
setShowEditModal(false);
setEditingItem(null);
fetchData(selectedDate);
} else {
const data = await res.json();
alert(data.message || '수정에 실패했습니다.');
}
} catch (err) {
alert('수정에 실패했습니다.');
} finally {
setSaving(false);
}
};
const handleSaveMemo = async () => {
try {
const res = await fetch('/finance/daily-fund/memo', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ date: selectedDate, memo, author: author || '재무팀' }),
});
const data = await res.json();
if (data.success) {
setUpdatedAt(data.data.updatedAt);
}
} catch (err) {
alert('메모 저장에 실패했습니다.');
}
const setLastMonth = () => {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastDay = new Date(now.getFullYear(), now.getMonth(), 0);
setStartDate(firstDay.toISOString().split('T')[0]);
setEndDate(lastDay.toISOString().split('T')[0]);
};
const handlePrint = () => window.print();
const handleDownload = () => {
const rows = [
['일일자금일보', formatDate(selectedDate)],
[],
['구분', '시간', '계좌', '적요', '금액', '카테고리', '비고'],
...incomeList.map(item => ['입금', item.time, item.accountName || '', item.description, item.amount, item.category, item.note]),
...expenseList.map(item => ['출금', item.time, item.accountName || '', item.description, item.amount, item.category, item.note]),
[],
['전일 잔액', '', '', '', previousBalance],
['금일 입금', '', '', '', totalIncome],
['금일 출금', '', '', '', totalExpense],
['금일 잔액', '', '', '', totalCurrentBalance]
];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `일일자금일보_${selectedDate}.csv`;
link.click();
};
const handleSendReport = () => {
setReportSent(true);
setShowSendModal(false);
setTimeout(() => setReportSent(false), 3000);
};
const renderTransactionList = (items, type, colorClass, signPrefix) => (
items.length === 0 ? (
<p className="text-center text-gray-400 py-8">{type === 'income' ? '입금' : '출금'} 내역이 없습니다</p>
) : (
<div className="space-y-3">
{items.map(item => (
<div key={item.id} className={`flex items-center justify-between p-3 ${colorClass} rounded-lg border cursor-pointer hover:opacity-80 transition-colors`} onClick={() => handleEditTransaction(type, item)}>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-400">{item.time}</span>
{item.accountName && <span className="px-1.5 py-0.5 rounded text-xs bg-gray-200 text-gray-600">{item.accountName}</span>}
<span className="text-xs text-gray-400">{item.category}</span>
</div>
<p className="text-sm text-gray-900">{item.description}</p>
{item.note && <p className="text-xs text-gray-400 mt-0.5">{item.note}</p>}
</div>
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${type === 'income' ? 'text-emerald-600' : 'text-rose-600'}`}>{signPrefix}{formatCurrency(item.amount)}</span>
<button onClick={(e) => { e.stopPropagation(); handleEditTransaction(type, item); }} className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors no-print"><Edit className="w-4 h-4" /></button>
<button onClick={(e) => { e.stopPropagation(); handleDeleteTransaction(type, item.id); }} className="p-1.5 text-gray-400 hover:text-rose-500 hover:bg-rose-50 rounded transition-colors no-print"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
))}
</div>
)
);
return (
<div className="bg-gray-50">
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6 no-print">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-emerald-100 rounded-xl"><FileSpreadsheet className="w-6 h-6 text-emerald-600" /></div>
<div><h1 className="text-xl font-bold text-gray-900">일일자금일보</h1><p className="text-sm text-gray-500">Daily Fund Report</p></div>
</div>
<div className="flex items-center gap-3">
<button onClick={handleDownload} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Download className="w-4 h-4" /><span className="text-sm">Excel</span></button>
<button onClick={handlePrint} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg"><Printer className="w-4 h-4" /><span className="text-sm">인쇄</span></button>
<button onClick={() => setShowSendModal(true)} className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg"><Send className="w-4 h-4" /><span className="text-sm">보고</span></button>
</div>
</div>
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6 no-print">
<div>
<h1 className="text-2xl font-bold text-gray-800">일일자금일보</h1>
<p className="text-sm text-gray-500 mt-1">Daily Fund Report</p>
</div>
</header>
{reportSent && (
<div className="fixed top-20 right-6 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 z-50 no-print">
<CheckCircle className="w-5 h-5" /><span>보고가 전송되었습니다.</span>
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6 no-print">
<div className="flex items-center justify-between">
<button onClick={() => moveDate(-1)} className="p-2 hover:bg-gray-100 rounded-lg"><ChevronLeft className="w-5 h-5 text-gray-600" /></button>
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-gray-400" />
<input type="date" value={selectedDate} onChange={(e) => setSelectedDate(e.target.value)} className="text-lg font-semibold text-gray-900 border-0 focus:ring-0 cursor-pointer" />
<span className="text-lg text-gray-600">({getDayOfWeek(selectedDate)})</span>
</div>
<button onClick={() => moveDate(1)} className="p-2 hover:bg-gray-100 rounded-lg"><ChevronRight className="w-5 h-5 text-gray-600" /></button>
<div className="flex flex-wrap items-center gap-2">
<button onClick={handlePrint} className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg text-sm">
인쇄
</button>
</div>
</div>
<div className="hidden print-only mb-8">
<h1 className="text-2xl font-bold text-center mb-2"> </h1>
<p className="text-center text-gray-600">{formatDate(selectedDate)}</p>
{/* 기간 선택 */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6 no-print">
<div className="flex flex-wrap items-center gap-4">
<span className="text-sm font-medium text-gray-600">기간</span>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<span className="text-gray-400">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<button onClick={setThisMonth} className="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm">
이번
</button>
<button onClick={setLastMonth} className="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm">
지난
</button>
<button onClick={fetchData} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
조회
</button>
<span className="text-sm text-gray-500 ml-auto">조회: {dailyReports.length}</span>
</div>
</div>
{loading ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<div className="flex items-center justify-center gap-2 text-gray-400">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
불러오는 ...
</div>
</div>
) : dailyReports.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-12 text-center text-gray-400">
해당 기간에 거래내역이 없습니다.
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4"><span className="text-sm text-gray-500">전일 잔액</span><div className="p-2 bg-gray-100 rounded-lg"><Wallet className="w-4 h-4 text-gray-600" /></div></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrencyShort(previousBalance)}</p>
<p className="text-xs text-gray-400 mt-1">{formatCurrency(previousBalance)}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
<div className="flex items-center justify-between mb-4"><span className="text-sm text-emerald-700">금일 입금</span><div className="p-2 bg-emerald-100 rounded-lg"><ArrowUpRight className="w-4 h-4 text-emerald-600" /></div></div>
<p className="text-2xl font-bold text-emerald-600">+{formatCurrencyShort(totalIncome)}</p>
<p className="text-xs text-emerald-500 mt-1">{formatCurrency(totalIncome)}</p>
</div>
<div className="bg-white rounded-xl border border-rose-200 p-6 bg-rose-50/30">
<div className="flex items-center justify-between mb-4"><span className="text-sm text-rose-700">금일 출금</span><div className="p-2 bg-rose-100 rounded-lg"><ArrowDownRight className="w-4 h-4 text-rose-600" /></div></div>
<p className="text-2xl font-bold text-rose-600">-{formatCurrencyShort(totalExpense)}</p>
<p className="text-xs text-rose-500 mt-1">{formatCurrency(totalExpense)}</p>
</div>
<div className="bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-6 text-white">
<div className="flex items-center justify-between mb-4"><span className="text-sm text-blue-100">금일 잔액</span><div className="p-2 bg-white/20 rounded-lg"><Wallet className="w-4 h-4 text-white" /></div></div>
<p className="text-2xl font-bold">{formatCurrencyShort(totalCurrentBalance)}</p>
<p className="text-xs text-blue-200 mt-1">{formatCurrency(totalCurrentBalance)}</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2"><div className="w-3 h-3 bg-emerald-500 rounded-full"></div>입금 내역</h3>
<button onClick={() => { setAddType('income'); setShowAddModal(true); }} className="flex items-center gap-1 px-3 py-1.5 text-sm text-emerald-600 hover:bg-emerald-50 rounded-lg no-print"><Plus className="w-4 h-4" />추가</button>
<div className="space-y-6">
{dailyReports.map((report, idx) => (
<div key={report.date} className="bg-white rounded-lg shadow-sm overflow-hidden">
{/* 일자 헤더 */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4">
<h2 className="text-lg font-bold text-white">
일자: {report.dateFormatted}
</h2>
</div>
{renderTransactionList(incomeList, 'income', 'bg-emerald-50/50 border-emerald-100', '+')}
{incomeList.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500 mb-2">카테고리별 집계</p>
<div className="flex flex-wrap gap-2">
{getCategoryTotals(incomeList).map(([cat, amount]) => (
<span key={cat} className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{cat}: {formatCurrencyShort(amount)}</span>
{/* 계좌별 잔고 테이블 */}
<div className="p-6 border-b border-gray-200">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 border">구분</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700 border">입금</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700 border">출금</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700 border">잔액</th>
</tr>
</thead>
<tbody>
{report.accounts.map((acc, accIdx) => (
<tr key={accIdx} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-800 border">
<span className="font-medium">{acc.bankName}</span>
<span className="text-gray-500 ml-2 text-xs">({acc.accountNum})</span>
</td>
<td className="px-4 py-3 text-sm text-right text-blue-600 border">
{acc.deposit > 0 ? formatCurrency(acc.deposit) : ''}
</td>
<td className="px-4 py-3 text-sm text-right text-red-600 border">
{acc.withdraw > 0 ? formatCurrency(acc.withdraw) : ''}
</td>
<td className="px-4 py-3 text-sm text-right font-semibold text-gray-800 border">
{formatCurrency(acc.balance)}
</td>
</tr>
))}
<tr className="bg-gray-100 font-semibold">
<td className="px-4 py-3 text-sm text-gray-800 border">합계</td>
<td className="px-4 py-3 text-sm text-right text-blue-600 border">
{formatCurrency(report.totalDeposit)}
</td>
<td className="px-4 py-3 text-sm text-right text-red-600 border">
{formatCurrency(report.totalWithdraw)}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-800 border">
{formatCurrency(report.accounts.reduce((sum, a) => sum + a.balance, 0))}
</td>
</tr>
</tbody>
</table>
</div>
{/* 입출금 내역 */}
<div className="p-6">
<h3 className="text-base font-semibold text-gray-800 mb-4 text-center bg-blue-50 py-2 rounded">
&lt;예금 입출금 내역&gt;
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 입금 내역 */}
<div>
<table className="w-full border-collapse">
<thead>
<tr className="bg-blue-50">
<th colSpan="3" className="px-4 py-2 text-center text-sm font-semibold text-blue-700 border">
입금
</th>
</tr>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left text-xs font-medium text-gray-600 border">입금처/적요</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-600 border w-28">금액</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-600 border w-28">잔액</th>
</tr>
</thead>
<tbody>
{report.deposits.length === 0 ? (
<tr><td colSpan="3" className="px-3 py-4 text-center text-gray-400 text-sm border">입금 내역 없음</td></tr>
) : (
<>
{report.deposits.map((d, dIdx) => (
<tr key={dIdx} className="hover:bg-gray-50">
<td className="px-3 py-2 text-sm text-gray-800 border">
<div className="font-medium">{d.cast || d.summary}</div>
<div className="text-xs text-gray-500">{d.bankName}</div>
</td>
<td className="px-3 py-2 text-sm text-right text-blue-600 font-medium border">
{formatCurrency(d.amount)}
</td>
<td className="px-3 py-2 text-sm text-right text-gray-600 border">
{formatCurrency(d.balance)}
</td>
</tr>
))}
<tr className="bg-blue-50 font-semibold">
<td className="px-3 py-2 text-sm text-gray-800 border">입금 합계</td>
<td className="px-3 py-2 text-sm text-right text-blue-700 border">
{formatCurrency(report.totalDeposit)}
</td>
<td className="px-3 py-2 border"></td>
</tr>
</>
)}
</tbody>
</table>
</div>
{/* 출금 내역 */}
<div>
<table className="w-full border-collapse">
<thead>
<tr className="bg-red-50">
<th colSpan="2" className="px-4 py-2 text-center text-sm font-semibold text-red-700 border">
출금
</th>
</tr>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left text-xs font-medium text-gray-600 border">출금처/적요</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-600 border w-28">금액</th>
</tr>
</thead>
<tbody>
{report.withdrawals.length === 0 ? (
<tr><td colSpan="2" className="px-3 py-4 text-center text-gray-400 text-sm border">출금 내역 없음</td></tr>
) : (
<>
{report.withdrawals.map((w, wIdx) => (
<tr key={wIdx} className="hover:bg-gray-50">
<td className="px-3 py-2 text-sm text-gray-800 border">
<div className="font-medium">{w.cast || w.summary}</div>
<div className="text-xs text-gray-500">{w.bankName}</div>
</td>
<td className="px-3 py-2 text-sm text-right text-red-600 font-medium border">
{formatCurrency(w.amount)}
</td>
</tr>
))}
<tr className="bg-red-50 font-semibold">
<td className="px-3 py-2 text-sm text-gray-800 border">출금 합계</td>
<td className="px-3 py-2 text-sm text-right text-red-700 border">
{formatCurrency(report.totalWithdraw)}
</td>
</tr>
</>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2"><div className="w-3 h-3 bg-rose-500 rounded-full"></div>출금 내역</h3>
<button onClick={() => { setAddType('expense'); setShowAddModal(true); }} className="flex items-center gap-1 px-3 py-1.5 text-sm text-rose-600 hover:bg-rose-50 rounded-lg no-print"><Plus className="w-4 h-4" />추가</button>
</div>
{renderTransactionList(expenseList, 'expense', 'bg-rose-50/50 border-rose-100', '-')}
{expenseList.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500 mb-2">카테고리별 집계</p>
<div className="flex flex-wrap gap-2">
{getCategoryTotals(expenseList).map(([cat, amount]) => (
<span key={cat} className="px-2 py-1 bg-rose-100 text-rose-700 rounded text-xs">{cat}: {formatCurrencyShort(amount)}</span>
))}
</div>
</div>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">특이사항 / 메모</h3>
<textarea value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="특이사항이나 메모를 입력하세요..." className="w-full h-24 px-4 py-3 border border-gray-200 rounded-lg resize-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 no-print" />
{memo && <div className="hidden print-only mt-2 p-3 bg-gray-50 rounded-lg"><p className="text-sm text-gray-700">{memo}</p></div>}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 text-sm text-gray-500">
<div>
{author && <p>작성자: {author}</p>}
{updatedAt && <p>작성일시: {updatedAt}</p>}
</div>
<button onClick={handleSaveMemo} className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg no-print"><Save className="w-4 h-4" />저장</button>
</div>
</div>
</>
)}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 no-print">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{addType === 'income' ? '입금 추가' : '출금 추가'}</h3>
<button onClick={() => setShowAddModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">시간</label><input type="time" value={newTransaction.time} onChange={(e) => setNewTransaction(prev => ({ ...prev, time: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">계좌/은행</label><input type="text" value={newTransaction.accountName} onChange={(e) => setNewTransaction(prev => ({ ...prev, accountName: e.target.value }))} placeholder="국민은행" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">적요 *</label><input type="text" value={newTransaction.description} onChange={(e) => setNewTransaction(prev => ({ ...prev, description: e.target.value }))} placeholder="거래 내용을 입력하세요" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label><input type="text" value={formatInputCurrency(newTransaction.amount)} onChange={(e) => setNewTransaction(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} placeholder="0" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<select value={newTransaction.category} onChange={(e) => setNewTransaction(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="">선택</option>
{addType === 'income' ? (<><option value="개발비">개발비</option><option value="구독료">구독료</option><option value="용역비">용역비</option><option value="기타수입">기타수입</option></>) : (<><option value="인건비">인건비</option><option value="운영비">운영비</option><option value="외주비">외주비</option><option value="임대료">임대료</option><option value="소모품비">소모품비</option><option value="기타지출">기타지출</option></>)}
</select>
</div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">비고</label><input type="text" value={newTransaction.note} onChange={(e) => setNewTransaction(prev => ({ ...prev, note: e.target.value }))} placeholder="추가 메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleAddTransaction} disabled={saving} className={`flex-1 px-4 py-2 text-white rounded-lg disabled:opacity-50 ${addType === 'income' ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-rose-600 hover:bg-rose-700'}`}>{saving ? '저장 중...' : '추가'}</button>
</div>
</div>
</div>
)}
{showSendModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 no-print">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">일일자금일보 보고</h3>
<button onClick={() => setShowSendModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-600 mb-2">보고 대상: <span className="font-medium text-gray-900">대표이사</span></p>
<p className="text-sm text-gray-600 mb-2">보고일: <span className="font-medium text-gray-900">{formatDate(selectedDate)}</span></p>
<div className="border-t border-gray-200 mt-3 pt-3">
<p className="text-sm text-gray-600">금일 잔액: <span className="font-bold text-blue-600">{formatCurrency(totalCurrentBalance)}</span></p>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setShowSendModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSendReport} className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg flex items-center justify-center gap-2"><Send className="w-4 h-4" />보고 전송</button>
</div>
</div>
</div>
)}
{showEditModal && editingItem && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 no-print">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-gray-900">{editType === 'income' ? '입금 수정' : '출금 수정'}</h3>
<button onClick={() => { setShowEditModal(false); setEditingItem(null); }} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">시간</label><input type="time" value={editingItem.time || ''} onChange={(e) => setEditingItem(prev => ({ ...prev, time: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">계좌/은행</label><input type="text" value={editingItem.accountName || ''} onChange={(e) => setEditingItem(prev => ({ ...prev, accountName: e.target.value }))} placeholder="국민은행" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">적요 *</label><input type="text" value={editingItem.description} onChange={(e) => setEditingItem(prev => ({ ...prev, description: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">금액 *</label><input type="text" value={formatInputCurrency(editingItem.amount)} onChange={(e) => setEditingItem(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-right" /></div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
<select value={editingItem.category || ''} onChange={(e) => setEditingItem(prev => ({ ...prev, category: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="">선택</option>
{editType === 'income' ? (<><option value="개발비">개발비</option><option value="구독료">구독료</option><option value="용역비">용역비</option><option value="기타수입">기타수입</option></>) : (<><option value="인건비">인건비</option><option value="운영비">운영비</option><option value="외주비">외주비</option><option value="임대료">임대료</option><option value="소모품비">소모품비</option><option value="기타지출">기타지출</option></>)}
</select>
</div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">비고</label><input type="text" value={editingItem.note || ''} onChange={(e) => setEditingItem(prev => ({ ...prev, note: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={() => { setShowEditModal(false); setEditingItem(null); handleDeleteTransaction(editType, editingItem.id); }} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>
<button onClick={() => { setShowEditModal(false); setEditingItem(null); }} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSaveEdit} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50">{saving ? '저장 중...' : '저장'}</button>
</div>
</div>
))}
</div>
)}
</div>

View File

@@ -746,6 +746,7 @@
// 일일자금일보 API
Route::prefix('daily-fund')->name('daily-fund.')->group(function () {
Route::get('/list', [\App\Http\Controllers\Finance\DailyFundController::class, 'index'])->name('list');
Route::get('/period-report', [\App\Http\Controllers\Finance\DailyFundController::class, 'periodReport'])->name('period-report');
Route::post('/store', [\App\Http\Controllers\Finance\DailyFundController::class, 'store'])->name('store');
Route::put('/{id}', [\App\Http\Controllers\Finance\DailyFundController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Finance\DailyFundController::class, 'destroy'])->name('destroy');