feat:매입/상담수수료/고객사정산/구독관리 목업 데이터를 실제 DB CRUD로 전환
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="consulting-fee-root"></div>
|
||||
@endsection
|
||||
|
||||
@@ -45,13 +46,11 @@
|
||||
const Users = createIcon('users');
|
||||
|
||||
function ConsultingFeeManagement() {
|
||||
const [fees, setFees] = useState([
|
||||
{ id: 1, date: '2026-01-21', consultant: '김상담', customer: '(주)제조산업', service: '기술 컨설팅', hours: 8, hourlyRate: 200000, amount: 1600000, status: 'pending', memo: 'MES 도입 상담' },
|
||||
{ id: 2, date: '2026-01-18', consultant: '박컨설', customer: '(주)스마트팩토리', service: '프로세스 컨설팅', hours: 16, hourlyRate: 250000, amount: 4000000, status: 'paid', memo: '공정 개선' },
|
||||
{ id: 3, date: '2026-01-15', consultant: '김상담', customer: '(주)디지털제조', service: '기술 컨설팅', hours: 4, hourlyRate: 200000, amount: 800000, status: 'paid', memo: 'ERP 연동 상담' },
|
||||
{ id: 4, date: '2026-01-10', consultant: '이자문', customer: '(주)AI산업', service: '전략 컨설팅', hours: 24, hourlyRate: 300000, amount: 7200000, status: 'pending', memo: 'DX 전략 수립' },
|
||||
{ id: 5, date: '2025-12-20', consultant: '박컨설', customer: '(주)테크솔루션', service: '프로세스 컨설팅', hours: 8, hourlyRate: 250000, amount: 2000000, status: 'paid', memo: '' },
|
||||
]);
|
||||
const [fees, setFees] = useState([]);
|
||||
const [stats, setStats] = useState({ totalAmount: 0, paidAmount: 0, pendingAmount: 0, totalHours: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
@@ -89,35 +88,84 @@ function ConsultingFeeManagement() {
|
||||
};
|
||||
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/finance/consulting-fees/list');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setFees(data.data);
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('조회 실패:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const filteredFees = fees.filter(item => {
|
||||
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.consultant.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesSearch = (item.customer || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(item.consultant || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
|
||||
const matchesConsultant = filterConsultant === 'all' || item.consultant === filterConsultant;
|
||||
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
|
||||
const matchesDate = (item.date || '') >= dateRange.start && (item.date || '') <= dateRange.end;
|
||||
return matchesSearch && matchesStatus && matchesConsultant && matchesDate;
|
||||
});
|
||||
|
||||
const totalAmount = filteredFees.reduce((sum, item) => sum + item.amount, 0);
|
||||
const paidAmount = filteredFees.filter(i => i.status === 'paid').reduce((sum, item) => sum + item.amount, 0);
|
||||
const pendingAmount = filteredFees.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.amount, 0);
|
||||
const totalHours = filteredFees.reduce((sum, item) => sum + item.hours, 0);
|
||||
|
||||
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
|
||||
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
|
||||
const handleSave = () => {
|
||||
if (!formData.customer || !formData.hours) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
const hours = parseInt(formData.hours) || 0;
|
||||
const hourlyRate = parseInt(formData.hourlyRate) || 0;
|
||||
const amount = parseInt(formData.amount) || hours * hourlyRate;
|
||||
if (modalMode === 'add') {
|
||||
setFees(prev => [{ id: Date.now(), ...formData, hours, hourlyRate, amount }, ...prev]);
|
||||
} else {
|
||||
setFees(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, hours, hourlyRate, amount } : item));
|
||||
}
|
||||
setShowModal(false); setEditingItem(null);
|
||||
const handleEdit = (item) => {
|
||||
setModalMode('edit');
|
||||
setEditingItem(item);
|
||||
const safeItem = {};
|
||||
Object.keys(initialFormState).forEach(key => { safeItem[key] = item[key] ?? ''; });
|
||||
setFormData(safeItem);
|
||||
setShowModal(true);
|
||||
};
|
||||
const handleSave = async () => {
|
||||
if (!formData.customer || !formData.hours) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = modalMode === 'add' ? '/finance/consulting-fees/store' : `/finance/consulting-fees/${editingItem.id}`;
|
||||
const body = { ...formData, hours: parseInt(formData.hours) || 0, hourlyRate: parseInt(formData.hourlyRate) || 0, amount: parseInt(formData.amount) || 0 };
|
||||
const res = await fetch(url, {
|
||||
method: modalMode === 'add' ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const errors = data.errors ? Object.values(data.errors).flat().join('\n') : data.message;
|
||||
alert(errors || '저장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('저장 실패:', err);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch(`/finance/consulting-fees/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken },
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 실패:', err);
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setFees(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
|
||||
|
||||
const handleDownload = () => {
|
||||
const rows = [['상담수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '컨설턴트', '고객사', '서비스', '시간', '시급', '금액', '상태'],
|
||||
@@ -145,19 +193,19 @@ function ConsultingFeeManagement() {
|
||||
<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-2"><span className="text-sm text-gray-500">총 시간</span><Users className="w-5 h-5 text-gray-400" /></div>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalHours}시간</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalHours}시간</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-cyan-200 p-6 bg-cyan-50/30">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-cyan-700">총 수수료</span><DollarSign className="w-5 h-5 text-cyan-500" /></div>
|
||||
<p className="text-2xl font-bold text-cyan-600">{formatCurrency(totalAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-cyan-600">{formatCurrency(stats.totalAmount)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-emerald-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">지급완료</span></div>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(paidAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.paidAmount)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-amber-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">지급예정</span></div>
|
||||
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-amber-600">{formatCurrency(stats.pendingAmount)}원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +246,9 @@ function ConsultingFeeManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredFees.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400"><div className="flex items-center justify-center gap-2"><svg className="animate-spin h-5 w-5 text-gray-400" 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>데이터를 불러오는 중...</div></td></tr>
|
||||
) : filteredFees.length === 0 ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : filteredFees.map(item => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
@@ -246,7 +296,7 @@ function ConsultingFeeManagement() {
|
||||
<div className="flex gap-3 mt-6">
|
||||
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
|
||||
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="customer-settlement-root"></div>
|
||||
@endsection
|
||||
|
||||
@@ -47,13 +48,11 @@
|
||||
const Clock = createIcon('clock');
|
||||
|
||||
function CustomerSettlementManagement() {
|
||||
const [settlements, setSettlements] = useState([
|
||||
{ id: 1, period: '2026-01', customer: '(주)제조산업', totalSales: 160000000, commission: 4800000, expense: 500000, netAmount: 154700000, status: 'pending', settledDate: '', memo: '' },
|
||||
{ id: 2, period: '2026-01', customer: '(주)테크솔루션', totalSales: 6000000, commission: 300000, expense: 0, netAmount: 5700000, status: 'settled', settledDate: '2026-01-20', memo: '' },
|
||||
{ id: 3, period: '2026-01', customer: '(주)디지털제조', totalSales: 80000000, commission: 2400000, expense: 200000, netAmount: 77400000, status: 'settled', settledDate: '2026-01-15', memo: '' },
|
||||
{ id: 4, period: '2025-12', customer: '(주)AI산업', totalSales: 36000000, commission: 720000, expense: 100000, netAmount: 35180000, status: 'settled', settledDate: '2025-12-31', memo: '연간 계약' },
|
||||
{ id: 5, period: '2025-12', customer: '(주)스마트팩토리', totalSales: 15000000, commission: 750000, expense: 0, netAmount: 14250000, status: 'settled', settledDate: '2025-12-28', memo: '' },
|
||||
]);
|
||||
const [settlements, setSettlements] = useState([]);
|
||||
const [stats, setStats] = useState({ totalSales: 0, totalCommission: 0, totalNet: 0, settledAmount: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
@@ -65,6 +64,23 @@ function CustomerSettlementManagement() {
|
||||
|
||||
const periods = [...new Set(settlements.map(s => s.period))].sort().reverse();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/finance/customer-settlements/list');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSettlements(data.data);
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('조회 실패:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const initialFormState = {
|
||||
period: new Date().toISOString().slice(0, 7),
|
||||
customer: '',
|
||||
@@ -87,33 +103,68 @@ function CustomerSettlementManagement() {
|
||||
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
|
||||
|
||||
const filteredSettlements = settlements.filter(item => {
|
||||
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesSearch = (item.customer || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
|
||||
const matchesPeriod = filterPeriod === 'all' || item.period === filterPeriod;
|
||||
return matchesSearch && matchesStatus && matchesPeriod;
|
||||
});
|
||||
|
||||
const totalSales = filteredSettlements.reduce((sum, item) => sum + item.totalSales, 0);
|
||||
const totalCommission = filteredSettlements.reduce((sum, item) => sum + item.commission, 0);
|
||||
const totalNet = filteredSettlements.reduce((sum, item) => sum + item.netAmount, 0);
|
||||
const settledAmount = filteredSettlements.filter(i => i.status === 'settled').reduce((sum, item) => sum + item.netAmount, 0);
|
||||
|
||||
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
|
||||
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
|
||||
const handleSave = () => {
|
||||
if (!formData.customer || !formData.totalSales) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
const totalSales = parseInt(formData.totalSales) || 0;
|
||||
const commission = parseInt(formData.commission) || 0;
|
||||
const expense = parseInt(formData.expense) || 0;
|
||||
const netAmount = totalSales - commission - expense;
|
||||
if (modalMode === 'add') {
|
||||
setSettlements(prev => [{ id: Date.now(), ...formData, totalSales, commission, expense, netAmount }, ...prev]);
|
||||
} else {
|
||||
setSettlements(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, totalSales, commission, expense, netAmount } : item));
|
||||
}
|
||||
setShowModal(false); setEditingItem(null);
|
||||
const handleEdit = (item) => {
|
||||
setModalMode('edit');
|
||||
setEditingItem(item);
|
||||
const safeItem = {};
|
||||
Object.keys(initialFormState).forEach(key => { safeItem[key] = item[key] ?? ''; });
|
||||
setFormData(safeItem);
|
||||
setShowModal(true);
|
||||
};
|
||||
const handleSave = async () => {
|
||||
if (!formData.customer || !formData.totalSales) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = modalMode === 'add' ? '/finance/customer-settlements/store' : `/finance/customer-settlements/${editingItem.id}`;
|
||||
const totalSalesVal = parseInt(formData.totalSales) || 0;
|
||||
const commissionVal = parseInt(formData.commission) || 0;
|
||||
const expenseVal = parseInt(formData.expense) || 0;
|
||||
const netAmount = totalSalesVal - commissionVal - expenseVal;
|
||||
const body = { ...formData, totalSales: totalSalesVal, commission: commissionVal, expense: expenseVal, netAmount };
|
||||
const res = await fetch(url, {
|
||||
method: modalMode === 'add' ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const errors = data.errors ? Object.values(data.errors).flat().join('\n') : data.message;
|
||||
alert(errors || '저장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('저장 실패:', err);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch(`/finance/customer-settlements/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken },
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 실패:', err);
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setSettlements(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
|
||||
|
||||
const handleDownload = () => {
|
||||
const rows = [['고객사별 정산'], [], ['정산월', '고객사', '매출액', '수수료', '비용', '정산금액', '상태', '정산일'],
|
||||
@@ -141,19 +192,19 @@ function CustomerSettlementManagement() {
|
||||
<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-2"><span className="text-sm text-gray-500">총 매출</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalSales)}원</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalSales)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-indigo-200 p-6 bg-indigo-50/30">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-indigo-700">정산 금액</span><Building2 className="w-5 h-5 text-indigo-500" /></div>
|
||||
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(totalNet)}원</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(stats.totalNet)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-emerald-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">정산완료</span><CheckCircle className="w-5 h-5 text-emerald-500" /></div>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(settledAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.settledAmount)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-rose-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-rose-700">수수료 합계</span></div>
|
||||
<p className="text-2xl font-bold text-rose-600">{formatCurrency(totalCommission)}원</p>
|
||||
<p className="text-2xl font-bold text-rose-600">{formatCurrency(stats.totalCommission)}원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +240,9 @@ function CustomerSettlementManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredSettlements.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400"><div className="flex items-center justify-center gap-2"><svg className="animate-spin h-5 w-5 text-gray-400" 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>데이터를 불러오는 중...</div></td></tr>
|
||||
) : filteredSettlements.length === 0 ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : filteredSettlements.map(item => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
@@ -241,7 +294,7 @@ function CustomerSettlementManagement() {
|
||||
<div className="flex gap-3 mt-6">
|
||||
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
|
||||
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="purchase-root"></div>
|
||||
@endsection
|
||||
|
||||
@@ -47,13 +48,11 @@
|
||||
const Building = createIcon('building');
|
||||
|
||||
function PurchaseManagement() {
|
||||
const [purchases, setPurchases] = useState([
|
||||
{ id: 1, date: '2026-01-20', vendor: 'AWS Korea', item: '클라우드 서비스', category: '운영비', amount: 2500000, vat: 250000, status: 'received', invoiceNo: 'PUR-2026-001', memo: '월정액' },
|
||||
{ id: 2, date: '2026-01-18', vendor: '(주)오피스', item: '사무용품', category: '소모품', amount: 500000, vat: 50000, status: 'received', invoiceNo: 'PUR-2026-002', memo: '' },
|
||||
{ id: 3, date: '2026-01-15', vendor: '김개발', item: '외주 개발', category: '외주비', amount: 15000000, vat: 1500000, status: 'pending', invoiceNo: 'PUR-2026-003', memo: '프론트엔드' },
|
||||
{ id: 4, date: '2026-01-10', vendor: '다나와', item: '노트북', category: '장비', amount: 1890000, vat: 189000, status: 'received', invoiceNo: 'PUR-2026-004', memo: '개발용' },
|
||||
{ id: 5, date: '2026-01-05', vendor: 'Google', item: 'Workspace 구독', category: '운영비', amount: 300000, vat: 30000, status: 'received', invoiceNo: 'PUR-2026-005', memo: '연간' },
|
||||
]);
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
const [stats, setStats] = useState({ totalAmount: 0, totalVat: 0, receivedAmount: 0, pendingAmount: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
@@ -90,34 +89,84 @@ function PurchaseManagement() {
|
||||
};
|
||||
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/finance/purchases/list');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setPurchases(data.data);
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('조회 실패:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const filteredPurchases = purchases.filter(item => {
|
||||
const matchesSearch = item.vendor.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.item.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesSearch = (item.vendor || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(item.item || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
|
||||
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
|
||||
const matchesDate = item.date >= dateRange.start && item.date <= dateRange.end;
|
||||
const matchesDate = (item.date || '') >= dateRange.start && (item.date || '') <= dateRange.end;
|
||||
return matchesSearch && matchesCategory && matchesStatus && matchesDate;
|
||||
});
|
||||
|
||||
const totalAmount = filteredPurchases.reduce((sum, item) => sum + item.amount, 0);
|
||||
const totalVat = filteredPurchases.reduce((sum, item) => sum + item.vat, 0);
|
||||
const receivedAmount = filteredPurchases.filter(i => i.status === 'received').reduce((sum, item) => sum + item.amount, 0);
|
||||
const pendingAmount = filteredPurchases.filter(i => i.status === 'pending').reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
|
||||
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
|
||||
const handleSave = () => {
|
||||
if (!formData.vendor || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
const amount = parseInt(formData.amount) || 0;
|
||||
const vat = parseInt(formData.vat) || Math.round(amount * 0.1);
|
||||
if (modalMode === 'add') {
|
||||
setPurchases(prev => [{ id: Date.now(), ...formData, amount, vat }, ...prev]);
|
||||
} else {
|
||||
setPurchases(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount, vat } : item));
|
||||
}
|
||||
setShowModal(false); setEditingItem(null);
|
||||
const handleEdit = (item) => {
|
||||
setModalMode('edit');
|
||||
setEditingItem(item);
|
||||
const safeItem = {};
|
||||
Object.keys(initialFormState).forEach(key => { safeItem[key] = item[key] ?? ''; });
|
||||
setFormData(safeItem);
|
||||
setShowModal(true);
|
||||
};
|
||||
const handleSave = async () => {
|
||||
if (!formData.vendor || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = modalMode === 'add' ? '/finance/purchases/store' : `/finance/purchases/${editingItem.id}`;
|
||||
const body = { ...formData, amount: parseInt(formData.amount) || 0, vat: parseInt(formData.vat) || 0 };
|
||||
const res = await fetch(url, {
|
||||
method: modalMode === 'add' ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const errors = data.errors ? Object.values(data.errors).flat().join('\n') : data.message;
|
||||
alert(errors || '저장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('저장 실패:', err);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch(`/finance/purchases/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken },
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 실패:', err);
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setPurchases(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
|
||||
|
||||
const handleDownload = () => {
|
||||
const rows = [['매입관리', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '공급자', '품목', '카테고리', '공급가액', 'VAT', '합계', '상태'],
|
||||
@@ -150,20 +199,20 @@ function PurchaseManagement() {
|
||||
<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-2"><span className="text-sm text-gray-500">총 매입</span><DollarSign className="w-5 h-5 text-gray-400" /></div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalAmount)}원</p>
|
||||
<p className="text-xs text-gray-400 mt-1">VAT 별도</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6 bg-orange-50/30">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-orange-700">수령완료</span><FileText className="w-5 h-5 text-orange-500" /></div>
|
||||
<p className="text-2xl font-bold text-orange-600">{formatCurrency(receivedAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{formatCurrency(stats.receivedAmount)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-amber-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">대기중</span><TrendingDown className="w-5 h-5 text-amber-500" /></div>
|
||||
<p className="text-2xl font-bold text-amber-600">{formatCurrency(pendingAmount)}원</p>
|
||||
<p className="text-2xl font-bold text-amber-600">{formatCurrency(stats.pendingAmount)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">VAT 합계</span></div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalVat)}원</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalVat)}원</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +253,9 @@ function PurchaseManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredPurchases.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400"><div className="flex items-center justify-center gap-2"><svg className="animate-spin h-5 w-5 text-gray-400" 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>데이터를 불러오는 중...</div></td></tr>
|
||||
) : filteredPurchases.length === 0 ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : filteredPurchases.map(item => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
@@ -252,7 +303,7 @@ function PurchaseManagement() {
|
||||
<div className="flex gap-3 mt-6">
|
||||
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
|
||||
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="subscription-root"></div>
|
||||
@endsection
|
||||
|
||||
@@ -48,13 +49,11 @@
|
||||
const Users = createIcon('users');
|
||||
|
||||
function SubscriptionManagement() {
|
||||
const [subscriptions, setSubscriptions] = useState([
|
||||
{ id: 1, customer: '(주)테크솔루션', plan: 'Enterprise', monthlyFee: 500000, billingCycle: 'monthly', startDate: '2025-06-01', nextBilling: '2026-02-01', status: 'active', users: 50, memo: '' },
|
||||
{ id: 2, customer: '(주)디지털제조', plan: 'Business', monthlyFee: 300000, billingCycle: 'yearly', startDate: '2025-01-01', nextBilling: '2026-01-01', status: 'active', users: 25, memo: '연간 계약 (10% 할인)' },
|
||||
{ id: 3, customer: '(주)AI산업', plan: 'Enterprise', monthlyFee: 500000, billingCycle: 'monthly', startDate: '2025-09-01', nextBilling: '2026-02-01', status: 'active', users: 40, memo: '' },
|
||||
{ id: 4, customer: '(주)스마트공장', plan: 'Starter', monthlyFee: 100000, billingCycle: 'monthly', startDate: '2025-11-01', nextBilling: '2026-02-01', status: 'trial', users: 5, memo: '무료 체험 중' },
|
||||
{ id: 5, customer: '(주)제조테크', plan: 'Business', monthlyFee: 300000, billingCycle: 'monthly', startDate: '2025-03-01', nextBilling: '2026-02-01', status: 'cancelled', users: 15, memo: '2026-01-31 해지 예정' },
|
||||
]);
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [stats, setStats] = useState({ activeCount: 0, monthlyRecurring: 0, yearlyRecurring: 0, totalUsers: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
@@ -88,32 +87,82 @@ function SubscriptionManagement() {
|
||||
};
|
||||
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/finance/subscriptions/list');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSubscriptions(data.data);
|
||||
setStats(data.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('조회 실패:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const filteredSubscriptions = subscriptions.filter(item => {
|
||||
const matchesSearch = item.customer.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesSearch = (item.customer || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
|
||||
const matchesPlan = filterPlan === 'all' || item.plan === filterPlan;
|
||||
return matchesSearch && matchesStatus && matchesPlan;
|
||||
});
|
||||
|
||||
const activeSubscriptions = subscriptions.filter(s => s.status === 'active');
|
||||
const monthlyRecurring = activeSubscriptions.reduce((sum, s) => sum + s.monthlyFee, 0);
|
||||
const yearlyRecurring = monthlyRecurring * 12;
|
||||
const totalUsers = activeSubscriptions.reduce((sum, s) => sum + s.users, 0);
|
||||
|
||||
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
|
||||
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
|
||||
const handleSave = () => {
|
||||
if (!formData.customer || !formData.monthlyFee) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
const monthlyFee = parseInt(formData.monthlyFee) || 0;
|
||||
const users = parseInt(formData.users) || 0;
|
||||
if (modalMode === 'add') {
|
||||
setSubscriptions(prev => [{ id: Date.now(), ...formData, monthlyFee, users }, ...prev]);
|
||||
} else {
|
||||
setSubscriptions(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, monthlyFee, users } : item));
|
||||
}
|
||||
setShowModal(false); setEditingItem(null);
|
||||
const handleEdit = (item) => {
|
||||
setModalMode('edit');
|
||||
setEditingItem(item);
|
||||
const safeItem = {};
|
||||
Object.keys(initialFormState).forEach(key => { safeItem[key] = item[key] ?? ''; });
|
||||
setFormData(safeItem);
|
||||
setShowModal(true);
|
||||
};
|
||||
const handleSave = async () => {
|
||||
if (!formData.customer || !formData.monthlyFee) { alert('필수 항목을 입력해주세요.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = modalMode === 'add' ? '/finance/subscriptions/store' : `/finance/subscriptions/${editingItem.id}`;
|
||||
const body = { ...formData, monthlyFee: parseInt(formData.monthlyFee) || 0, users: parseInt(formData.users) || 0 };
|
||||
const res = await fetch(url, {
|
||||
method: modalMode === 'add' ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const errors = data.errors ? Object.values(data.errors).flat().join('\n') : data.message;
|
||||
alert(errors || '저장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('저장 실패:', err);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetch(`/finance/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken },
|
||||
});
|
||||
if (res.ok) {
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('삭제 실패:', err);
|
||||
alert('삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setSubscriptions(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
|
||||
|
||||
const handleDownload = () => {
|
||||
const rows = [['구독관리'], [], ['고객사', '플랜', '월 요금', '결제주기', '시작일', '다음결제', '상태', '사용자수'],
|
||||
@@ -154,19 +203,19 @@ function SubscriptionManagement() {
|
||||
<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-2"><span className="text-sm text-gray-500">활성 구독</span><CheckCircle className="w-5 h-5 text-gray-400" /></div>
|
||||
<p className="text-2xl font-bold text-gray-900">{activeSubscriptions.length}개</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.activeCount}개</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-teal-200 p-6 bg-teal-50/30">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-teal-700">월 반복 수익(MRR)</span><DollarSign className="w-5 h-5 text-teal-500" /></div>
|
||||
<p className="text-2xl font-bold text-teal-600">{formatCurrency(monthlyRecurring)}원</p>
|
||||
<p className="text-2xl font-bold text-teal-600">{formatCurrency(stats.monthlyRecurring)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-emerald-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">연 반복 수익(ARR)</span></div>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(yearlyRecurring)}원</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.yearlyRecurring)}원</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">총 사용자</span><Users className="w-5 h-5 text-gray-400" /></div>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalUsers}명</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalUsers}명</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,7 +251,9 @@ function SubscriptionManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredSubscriptions.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400"><div className="flex items-center justify-center gap-2"><svg className="animate-spin h-5 w-5 text-gray-400" 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>데이터를 불러오는 중...</div></td></tr>
|
||||
) : filteredSubscriptions.length === 0 ? (
|
||||
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : filteredSubscriptions.map(item => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
@@ -252,7 +303,7 @@ function SubscriptionManagement() {
|
||||
<div className="flex gap-3 mt-6">
|
||||
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</button>}
|
||||
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user