From fa45caf9d97315b44fce11a5a2f98f1fbbbf7935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 22:49:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EB=A7=A4=EC=9E=85/=EC=83=81=EB=8B=B4?= =?UTF-8?q?=EC=88=98=EC=88=98=EB=A3=8C/=EA=B3=A0=EA=B0=9D=EC=82=AC?= =?UTF-8?q?=EC=A0=95=EC=82=B0/=EA=B5=AC=EB=8F=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=AA=A9=EC=97=85=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20DB=20CRUD=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../views/finance/consulting-fee.blade.php | 118 ++++++++++++----- .../finance/customer-settlement.blade.php | 119 +++++++++++++----- resources/views/finance/purchase.blade.php | 117 ++++++++++++----- .../views/finance/subscription.blade.php | 113 ++++++++++++----- 4 files changed, 336 insertions(+), 131 deletions(-) diff --git a/resources/views/finance/consulting-fee.blade.php b/resources/views/finance/consulting-fee.blade.php index f2cc902f..57995bb3 100644 --- a/resources/views/finance/consulting-fee.blade.php +++ b/resources/views/finance/consulting-fee.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@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() {
총 시간
-

{totalHours}시간

+

{stats.totalHours}시간

총 수수료
-

{formatCurrency(totalAmount)}원

+

{formatCurrency(stats.totalAmount)}원

지급완료
-

{formatCurrency(paidAmount)}원

+

{formatCurrency(stats.paidAmount)}원

지급예정
-

{formatCurrency(pendingAmount)}원

+

{formatCurrency(stats.pendingAmount)}원

@@ -198,7 +246,9 @@ function ConsultingFeeManagement() { - {filteredFees.length === 0 ? ( + {loading ? ( +
데이터를 불러오는 중...
+ ) : filteredFees.length === 0 ? ( 데이터가 없습니다. ) : filteredFees.map(item => ( handleEdit(item)}> @@ -246,7 +296,7 @@ function ConsultingFeeManagement() {
{modalMode === 'edit' && } - +
diff --git a/resources/views/finance/customer-settlement.blade.php b/resources/views/finance/customer-settlement.blade.php index 1b235dc0..99d7e2fd 100644 --- a/resources/views/finance/customer-settlement.blade.php +++ b/resources/views/finance/customer-settlement.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@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() {
총 매출
-

{formatCurrency(totalSales)}원

+

{formatCurrency(stats.totalSales)}원

정산 금액
-

{formatCurrency(totalNet)}원

+

{formatCurrency(stats.totalNet)}원

정산완료
-

{formatCurrency(settledAmount)}원

+

{formatCurrency(stats.settledAmount)}원

수수료 합계
-

{formatCurrency(totalCommission)}원

+

{formatCurrency(stats.totalCommission)}원

@@ -189,7 +240,9 @@ function CustomerSettlementManagement() { - {filteredSettlements.length === 0 ? ( + {loading ? ( +
데이터를 불러오는 중...
+ ) : filteredSettlements.length === 0 ? ( 데이터가 없습니다. ) : filteredSettlements.map(item => ( handleEdit(item)}> @@ -241,7 +294,7 @@ function CustomerSettlementManagement() {
{modalMode === 'edit' && } - +
diff --git a/resources/views/finance/purchase.blade.php b/resources/views/finance/purchase.blade.php index daf4f6c8..796f7c81 100644 --- a/resources/views/finance/purchase.blade.php +++ b/resources/views/finance/purchase.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@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() {
총 매입
-

{formatCurrency(totalAmount)}원

+

{formatCurrency(stats.totalAmount)}원

VAT 별도

수령완료
-

{formatCurrency(receivedAmount)}원

+

{formatCurrency(stats.receivedAmount)}원

대기중
-

{formatCurrency(pendingAmount)}원

+

{formatCurrency(stats.pendingAmount)}원

VAT 합계
-

{formatCurrency(totalVat)}원

+

{formatCurrency(stats.totalVat)}원

@@ -204,7 +253,9 @@ function PurchaseManagement() { - {filteredPurchases.length === 0 ? ( + {loading ? ( +
데이터를 불러오는 중...
+ ) : filteredPurchases.length === 0 ? ( 데이터가 없습니다. ) : filteredPurchases.map(item => ( handleEdit(item)}> @@ -252,7 +303,7 @@ function PurchaseManagement() {
{modalMode === 'edit' && } - +
diff --git a/resources/views/finance/subscription.blade.php b/resources/views/finance/subscription.blade.php index 608f92e8..e5c2c608 100644 --- a/resources/views/finance/subscription.blade.php +++ b/resources/views/finance/subscription.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@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() {
활성 구독
-

{activeSubscriptions.length}개

+

{stats.activeCount}개

월 반복 수익(MRR)
-

{formatCurrency(monthlyRecurring)}원

+

{formatCurrency(stats.monthlyRecurring)}원

연 반복 수익(ARR)
-

{formatCurrency(yearlyRecurring)}원

+

{formatCurrency(stats.yearlyRecurring)}원

총 사용자
-

{totalUsers}명

+

{stats.totalUsers}명

@@ -202,7 +251,9 @@ function SubscriptionManagement() { - {filteredSubscriptions.length === 0 ? ( + {loading ? ( +
데이터를 불러오는 중...
+ ) : filteredSubscriptions.length === 0 ? ( 데이터가 없습니다. ) : filteredSubscriptions.map(item => ( handleEdit(item)}> @@ -252,7 +303,7 @@ function SubscriptionManagement() {
{modalMode === 'edit' && } - +