375 lines
13 KiB
JavaScript
375 lines
13 KiB
JavaScript
import { useState, useEffect } from 'react';
|
||||
|
|
import { useRouter } from 'next/router';
|
|||
|
|
import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api';
|
|||
|
|
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
|||
|
|
import AppShell from '../components/AppShell';
|
|||
|
|
import { Icon } from '../components/icons';
|
|||
|
|
import { useToast } from '../components/ToastProvider';
|
|||
|
|
import { useConfirm } from '../components/ConfirmProvider';
|
|||
|
|
|
|||
|
|
export default function Recurring() {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const { showToast } = useToast();
|
|||
|
|
const { confirm } = useConfirm();
|
|||
|
|
const [bills, setBills] = useState([]);
|
|||
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|||
|
|
const [editingBill, setEditingBill] = useState(null);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [submitting, setSubmitting] = useState(false);
|
|||
|
|
const [deleting, setDeleting] = useState(false);
|
|||
|
|
const [applyingId, setApplyingId] = useState(null);
|
|||
|
|
const [formError, setFormError] = useState('');
|
|||
|
|
const [formData, setFormData] = useState({
|
|||
|
|
type: 'expense',
|
|||
|
|
amount: '',
|
|||
|
|
category: '',
|
|||
|
|
day: '1',
|
|||
|
|
note: '',
|
|||
|
|
is_active: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!isLoggedIn()) {
|
|||
|
|
router.push('/login');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
loadBills();
|
|||
|
|
}, [router]);
|
|||
|
|
|
|||
|
|
const loadBills = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const data = await getRecurringBills();
|
|||
|
|
setBills(data);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(err);
|
|||
|
|
showToast(err.message, { tone: 'error' });
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const openAddModal = () => {
|
|||
|
|
setEditingBill(null);
|
|||
|
|
setFormData({
|
|||
|
|
type: 'expense',
|
|||
|
|
amount: '',
|
|||
|
|
category: '',
|
|||
|
|
day: '1',
|
|||
|
|
note: '',
|
|||
|
|
is_active: true,
|
|||
|
|
});
|
|||
|
|
setFormError('');
|
|||
|
|
setModalOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const openEditModal = (bill) => {
|
|||
|
|
setEditingBill(bill);
|
|||
|
|
setFormData({
|
|||
|
|
type: bill.type,
|
|||
|
|
amount: bill.amount.toString(),
|
|||
|
|
category: bill.category,
|
|||
|
|
day: bill.day.toString(),
|
|||
|
|
note: bill.note || '',
|
|||
|
|
is_active: !!bill.is_active,
|
|||
|
|
});
|
|||
|
|
setFormError('');
|
|||
|
|
setModalOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const closeModal = () => {
|
|||
|
|
if (submitting || deleting) return;
|
|||
|
|
setModalOpen(false);
|
|||
|
|
setEditingBill(null);
|
|||
|
|
setFormError('');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (!formData.amount || !formData.category || !formData.day) {
|
|||
|
|
setFormError('请填写完整信息');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setSubmitting(true);
|
|||
|
|
setFormError('');
|
|||
|
|
try {
|
|||
|
|
const data = {
|
|||
|
|
type: formData.type,
|
|||
|
|
amount: parseFloat(formData.amount),
|
|||
|
|
category: formData.category,
|
|||
|
|
day: parseInt(formData.day),
|
|||
|
|
note: formData.note,
|
|||
|
|
is_active: formData.is_active ? 1 : 0,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (editingBill) {
|
|||
|
|
await updateRecurringBill(editingBill.id, data);
|
|||
|
|
showToast('周期账单已更新', { tone: 'success' });
|
|||
|
|
} else {
|
|||
|
|
await createRecurringBill(data);
|
|||
|
|
showToast('周期账单已创建', { tone: 'success' });
|
|||
|
|
}
|
|||
|
|
closeModal();
|
|||
|
|
await loadBills();
|
|||
|
|
} catch (err) {
|
|||
|
|
setFormError(err.message);
|
|||
|
|
showToast(err.message, { tone: 'error' });
|
|||
|
|
} finally {
|
|||
|
|
setSubmitting(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async () => {
|
|||
|
|
if (!editingBill) return;
|
|||
|
|
const shouldDelete = await confirm({
|
|||
|
|
title: '删除这条周期账单?',
|
|||
|
|
description: '删除后将无法恢复,请确认是否继续。',
|
|||
|
|
confirmText: '删除',
|
|||
|
|
});
|
|||
|
|
if (!shouldDelete) return;
|
|||
|
|
|
|||
|
|
setDeleting(true);
|
|||
|
|
try {
|
|||
|
|
await deleteRecurringBill(editingBill.id);
|
|||
|
|
closeModal();
|
|||
|
|
showToast('周期账单已删除', { tone: 'success' });
|
|||
|
|
await loadBills();
|
|||
|
|
} catch (err) {
|
|||
|
|
setFormError(err.message);
|
|||
|
|
showToast(err.message, { tone: 'error' });
|
|||
|
|
} finally {
|
|||
|
|
setDeleting(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleApply = async (id) => {
|
|||
|
|
setApplyingId(id);
|
|||
|
|
try {
|
|||
|
|
await applyRecurringBill(id);
|
|||
|
|
showToast('已添加到账目', { tone: 'success' });
|
|||
|
|
} catch (err) {
|
|||
|
|
showToast(err.message, { tone: 'error' });
|
|||
|
|
} finally {
|
|||
|
|
setApplyingId(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleToggleActive = async (bill, event) => {
|
|||
|
|
event.stopPropagation();
|
|||
|
|
try {
|
|||
|
|
await updateRecurringBill(bill.id, {
|
|||
|
|
...bill,
|
|||
|
|
is_active: bill.is_active ? 0 : 1,
|
|||
|
|
});
|
|||
|
|
showToast(bill.is_active ? '已停用周期账单' : '已启用周期账单', { tone: 'success' });
|
|||
|
|
await loadBills();
|
|||
|
|
} catch (err) {
|
|||
|
|
showToast(err.message, { tone: 'error' });
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const days = Array.from({ length: 28 }, (_, index) => index + 1);
|
|||
|
|
|
|||
|
|
const actions = (
|
|||
|
|
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
|||
|
|
<Icon name="settings" size={18} />
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<AppShell title="周期账单" subtitle="管理固定收入与支出节奏" actions={actions} compactHeader>
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="card loading-state">加载中...</div>
|
|||
|
|
) : bills.length === 0 ? (
|
|||
|
|
<div className="card empty-state empty-state--card">
|
|||
|
|
<div className="empty-state__icon"><Icon name="repeat" size={24} /></div>
|
|||
|
|
<p>暂无周期性账单</p>
|
|||
|
|
<p className="sub">点击右下角新增定期收入或支出。</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<section className="card section-card">
|
|||
|
|
<div className="section-heading section-heading--compact">
|
|||
|
|
<div>
|
|||
|
|
<p className="eyebrow">账单列表</p>
|
|||
|
|
<h2>{bills.length} 条周期账单</h2>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="bills-list">
|
|||
|
|
{bills.map((bill) => {
|
|||
|
|
const categories = DEFAULT_CATEGORIES[bill.type];
|
|||
|
|
const cat = categories.find((item) => item.name === bill.category) || { icon: '📝' };
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={bill.id}
|
|||
|
|
type="button"
|
|||
|
|
className={`bill-item ${bill.is_active ? '' : 'inactive'}`}
|
|||
|
|
onClick={() => openEditModal(bill)}
|
|||
|
|
>
|
|||
|
|
<div className="bill-left">
|
|||
|
|
<div className="bill-icon">{cat.icon}</div>
|
|||
|
|
<div className="bill-info">
|
|||
|
|
<div className="bill-row">
|
|||
|
|
<div className="bill-category">{bill.category}</div>
|
|||
|
|
<span className={`status-chip ${bill.is_active ? 'status-chip--success' : ''}`}>
|
|||
|
|
{bill.is_active ? '启用中' : '已停用'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="bill-day">每月 {bill.day} 日</div>
|
|||
|
|
{bill.note && <div className="bill-note">{bill.note}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="bill-right">
|
|||
|
|
<div className={`bill-amount ${bill.type}`}>
|
|||
|
|
{bill.type === 'income' ? '+' : '-'}{bill.amount.toFixed(2)}
|
|||
|
|
</div>
|
|||
|
|
<div className="bill-actions">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="btn btn-secondary btn-inline btn-small"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
handleApply(bill.id);
|
|||
|
|
}}
|
|||
|
|
disabled={applyingId === bill.id}
|
|||
|
|
>
|
|||
|
|
{applyingId === bill.id ? '应用中...' : '应用'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="btn btn-ghost btn-inline btn-small"
|
|||
|
|
onClick={(event) => handleToggleActive(bill, event)}
|
|||
|
|
>
|
|||
|
|
{bill.is_active ? '停用' : '启用'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<button type="button" className="fab" aria-label="新增账单" onClick={openAddModal}>
|
|||
|
|
<Icon name="plus" size={24} />
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{modalOpen && (
|
|||
|
|
<div className="modal-overlay" onClick={closeModal}>
|
|||
|
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|||
|
|
<div className="modal-header">
|
|||
|
|
<div>
|
|||
|
|
<p className="eyebrow">{editingBill ? '编辑账单' : '新增账单'}</p>
|
|||
|
|
<h2>{editingBill ? '更新周期账单' : '创建周期账单'}</h2>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" className="icon-button icon-button--ghost" aria-label="关闭弹窗" onClick={closeModal}>
|
|||
|
|
<Icon name="close" size={18} />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{formError && <div className="error-msg">{formError}</div>}
|
|||
|
|
<form onSubmit={handleSubmit}>
|
|||
|
|
<div className="type-switch">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className={`type-btn expense ${formData.type === 'expense' ? 'active' : ''}`}
|
|||
|
|
onClick={() => setFormData({ ...formData, type: 'expense', category: '' })}
|
|||
|
|
disabled={submitting || deleting}
|
|||
|
|
>
|
|||
|
|
支出
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className={`type-btn income ${formData.type === 'income' ? 'active' : ''}`}
|
|||
|
|
onClick={() => setFormData({ ...formData, type: 'income', category: '' })}
|
|||
|
|
disabled={submitting || deleting}
|
|||
|
|
>
|
|||
|
|
收入
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label htmlFor="bill-amount">金额</label>
|
|||
|
|
<input
|
|||
|
|
id="bill-amount"
|
|||
|
|
type="number"
|
|||
|
|
className="amount-input"
|
|||
|
|
value={formData.amount}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
|||
|
|
placeholder="0.00"
|
|||
|
|
step="0.01"
|
|||
|
|
required
|
|||
|
|
disabled={submitting || deleting}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label>分类</label>
|
|||
|
|
<div className="category-grid">
|
|||
|
|
{DEFAULT_CATEGORIES[formData.type].map((cat) => (
|
|||
|
|
<button
|
|||
|
|
key={cat.name}
|
|||
|
|
type="button"
|
|||
|
|
className={`category-btn ${formData.category === cat.name ? 'active' : ''}`}
|
|||
|
|
onClick={() => setFormData({ ...formData, category: cat.name })}
|
|||
|
|
disabled={submitting || deleting}
|
|||
|
|
>
|
|||
|
|
<span className="icon">{cat.icon}</span>
|
|||
|
|
<span>{cat.name}</span>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label htmlFor="bill-day">日期(每月几号)</label>
|
|||
|
|
<select id="bill-day" value={formData.day} onChange={(e) => setFormData({ ...formData, day: e.target.value })} disabled={submitting || deleting}>
|
|||
|
|
{days.map((day) => (
|
|||
|
|
<option key={day} value={day}>{day} 日</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div className="form-group">
|
|||
|
|
<label htmlFor="bill-note">备注(可选)</label>
|
|||
|
|
<input
|
|||
|
|
id="bill-note"
|
|||
|
|
type="text"
|
|||
|
|
value={formData.note}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
|||
|
|
placeholder="添加备注..."
|
|||
|
|
disabled={submitting || deleting}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{editingBill && (
|
|||
|
|
<div className="form-group form-group--inline">
|
|||
|
|
<label className="checkbox-label">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={formData.is_active}
|
|||
|
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
|||
|
|
disabled={submitting || deleting}
|
|||
|
|
/>
|
|||
|
|
启用此账单
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div className="modal-actions">
|
|||
|
|
<button type="submit" className="btn btn-primary" disabled={submitting || deleting}>
|
|||
|
|
{submitting ? (
|
|||
|
|
<span className="button-content">
|
|||
|
|
<Icon name="spinner" size={16} className="is-spinning" />
|
|||
|
|
保存中...
|
|||
|
|
</span>
|
|||
|
|
) : '保存'}
|
|||
|
|
</button>
|
|||
|
|
{editingBill && (
|
|||
|
|
<button type="button" className="btn btn-danger" onClick={handleDelete} disabled={submitting || deleting}>
|
|||
|
|
{deleting ? '删除中...' : '删除'}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</AppShell>
|
|||
|
|
);
|
|||
|
|
}
|