247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { BillReminder } from '../types';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
interface BillRemindersProps {
|
|
reminders: BillReminder[];
|
|
onAdd: (reminder: BillReminder) => void;
|
|
onUpdate: (reminder: BillReminder) => void;
|
|
onDelete: (id: string) => void;
|
|
onToggle: (id: string) => void;
|
|
}
|
|
|
|
// Helper to calculate days remaining
|
|
export const getDaysRemaining = (r: BillReminder): number => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
let targetDate = new Date();
|
|
targetDate.setHours(0, 0, 0, 0);
|
|
|
|
if (r.frequency === 'monthly') {
|
|
const day = parseInt(r.targetDate);
|
|
targetDate.setDate(day);
|
|
|
|
// If the day has passed this month, move to next month
|
|
if (targetDate < today) {
|
|
targetDate.setMonth(targetDate.getMonth() + 1);
|
|
}
|
|
} else {
|
|
// Once
|
|
targetDate = new Date(r.targetDate);
|
|
targetDate.setHours(0, 0, 0, 0);
|
|
}
|
|
|
|
const diffTime = targetDate.getTime() - today.getTime();
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
return diffDays;
|
|
};
|
|
|
|
const BillReminders: React.FC<BillRemindersProps> = ({ reminders, onAdd, onUpdate, onDelete, onToggle }) => {
|
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
const [title, setTitle] = useState('');
|
|
const [amount, setAmount] = useState('');
|
|
const [frequency, setFrequency] = useState<'monthly' | 'once'>('monthly');
|
|
const [targetDate, setTargetDate] = useState(''); // '15' or '2023-10-15'
|
|
|
|
const resetForm = () => {
|
|
setTitle('');
|
|
setAmount('');
|
|
setFrequency('monthly');
|
|
setTargetDate('');
|
|
setEditingId(null);
|
|
setIsFormOpen(false);
|
|
};
|
|
|
|
const startEdit = (r: BillReminder) => {
|
|
setEditingId(r.id);
|
|
setTitle(r.title);
|
|
setAmount(r.amount ? r.amount.toString() : '');
|
|
setFrequency(r.frequency);
|
|
setTargetDate(r.targetDate);
|
|
setIsFormOpen(true);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
// Amount is now optional, check title and targetDate only
|
|
if (!title || !targetDate) return;
|
|
|
|
const parsedAmount = parseFloat(amount);
|
|
|
|
const reminderData: BillReminder = {
|
|
id: editingId || uuidv4(),
|
|
title,
|
|
amount: isNaN(parsedAmount) ? undefined : parsedAmount,
|
|
frequency,
|
|
targetDate,
|
|
isCompleted: editingId ? reminders.find(r => r.id === editingId)?.isCompleted || false : false
|
|
};
|
|
|
|
if (editingId) {
|
|
onUpdate(reminderData);
|
|
} else {
|
|
onAdd(reminderData);
|
|
}
|
|
resetForm();
|
|
};
|
|
|
|
const getDueText = (r: BillReminder) => {
|
|
if (r.frequency === 'monthly') {
|
|
return `每月 ${r.targetDate} 日`;
|
|
}
|
|
return r.targetDate; // YYYY-MM-DD
|
|
};
|
|
|
|
const renderCountdown = (r: BillReminder) => {
|
|
const days = getDaysRemaining(r);
|
|
if (r.isCompleted) return <span className="text-emerald-600 font-bold text-[10px] bg-emerald-50 px-2 py-0.5 rounded-full border border-emerald-100">已完成</span>;
|
|
if (days < 0) return <span className="text-rose-600 font-bold text-[10px] bg-rose-50 px-2 py-0.5 rounded-full border border-rose-100">逾期 {Math.abs(days)} 天</span>;
|
|
if (days === 0) return <span className="text-rose-600 font-bold text-[10px] bg-rose-50 px-2 py-0.5 rounded-full border border-rose-100 animate-pulse">今天到期</span>;
|
|
if (days <= 3) return <span className="text-orange-600 font-bold text-[10px] bg-orange-50 px-2 py-0.5 rounded-full border border-orange-100">剩 {days} 天</span>;
|
|
return <span className="text-indigo-600 font-bold text-[10px] bg-indigo-50 px-2 py-0.5 rounded-full border border-indigo-100">剩 {days} 天</span>;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between px-2">
|
|
<h3 className="text-gray-700 font-bold flex items-center gap-2 text-sm">
|
|
<svg className="w-5 h-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
待办 & 还款提醒
|
|
</h3>
|
|
<button
|
|
onClick={() => {
|
|
if (isFormOpen) resetForm();
|
|
else setIsFormOpen(true);
|
|
}}
|
|
className="text-indigo-600 text-xs font-semibold hover:bg-indigo-50 px-2 py-1 rounded-lg transition-colors bg-white shadow-sm border border-indigo-100"
|
|
>
|
|
{isFormOpen ? '取消' : '+ 新增'}
|
|
</button>
|
|
</div>
|
|
|
|
{isFormOpen && (
|
|
<form onSubmit={handleSubmit} className="bg-white p-4 rounded-2xl shadow-lg border border-indigo-50 animate-float-in relative z-20">
|
|
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
|
<div className="w-1 h-4 bg-indigo-500 rounded-full"></div>
|
|
{editingId ? '编辑提醒' : '新提醒'}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
<input
|
|
placeholder="项目名称 (如: 房贷、信用卡)"
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
className="w-full p-2 bg-gray-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
placeholder="金额 (选填)"
|
|
value={amount}
|
|
onChange={e => setAmount(e.target.value)}
|
|
className="flex-1 p-2 bg-gray-50 border-none rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
|
|
/>
|
|
<select
|
|
value={frequency}
|
|
onChange={e => setFrequency(e.target.value as any)}
|
|
className="p-2 bg-gray-50 border-none rounded-xl text-sm outline-none"
|
|
>
|
|
<option value="monthly">每月</option>
|
|
<option value="once">单次</option>
|
|
</select>
|
|
</div>
|
|
{frequency === 'monthly' ? (
|
|
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded-xl">
|
|
<span>每月</span>
|
|
<input
|
|
type="number"
|
|
min="1" max="31"
|
|
placeholder="日"
|
|
value={targetDate}
|
|
onChange={e => setTargetDate(e.target.value)}
|
|
className="w-12 p-1 border-b border-indigo-200 text-center bg-transparent focus:border-indigo-500 outline-none"
|
|
/>
|
|
<span>号还款</span>
|
|
</div>
|
|
) : (
|
|
<input
|
|
type="date"
|
|
value={targetDate}
|
|
onChange={e => setTargetDate(e.target.value)}
|
|
className="w-full p-2 bg-gray-50 border-none rounded-xl text-sm text-gray-600 outline-none"
|
|
/>
|
|
)}
|
|
<button type="submit" className="w-full py-2 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-md shadow-indigo-200 hover:bg-indigo-700 transition-transform active:scale-95">
|
|
{editingId ? '保存' : '添加'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{reminders.length === 0 && !isFormOpen && (
|
|
<div className="text-center py-6 text-gray-400 text-xs bg-white rounded-2xl border border-dashed border-gray-200">
|
|
<p className="text-xl mb-1">📅</p>
|
|
暂无待办事项
|
|
</div>
|
|
)}
|
|
{reminders.map(r => (
|
|
<div key={r.id} className={`relative overflow-hidden p-3 rounded-2xl border transition-all duration-300 ${r.isCompleted ? 'bg-gray-50 border-gray-100 opacity-60' : 'bg-white border-gray-100 shadow-sm'}`}>
|
|
{/* Decorative side bar */}
|
|
<div className={`absolute left-0 top-0 bottom-0 w-1 ${r.isCompleted ? 'bg-gray-300' : (getDaysRemaining(r) <= 3 ? 'bg-rose-500' : 'bg-indigo-500')}`}></div>
|
|
|
|
<div className="flex items-center justify-between pl-3 pr-1">
|
|
{/* Left: Info */}
|
|
<div className="flex-1 min-w-0 mr-2">
|
|
{/* Row 1: Checkbox + Title + Amount */}
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<button
|
|
onClick={() => onToggle(r.id)}
|
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors shrink-0 ${r.isCompleted ? 'bg-emerald-500 border-emerald-500' : 'border-gray-300 hover:border-indigo-500'}`}
|
|
>
|
|
{r.isCompleted && <svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
|
|
</button>
|
|
<span className={`font-bold text-sm truncate ${r.isCompleted ? 'line-through text-gray-400' : 'text-gray-800'}`}>{r.title}</span>
|
|
{r.amount != null && (
|
|
<span className="font-mono text-gray-500 font-bold text-[10px] bg-gray-50 px-1 rounded">¥{r.amount}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 2: Date + Countdown */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
|
<span>{getDueText(r)}</span>
|
|
</div>
|
|
{renderCountdown(r)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Actions (Separated) */}
|
|
<div className="flex items-center gap-6 border-l border-gray-100 pl-4 py-1">
|
|
<button
|
|
onClick={() => startEdit(r)}
|
|
className="text-gray-400 hover:text-indigo-600 transition-colors p-1"
|
|
title="编辑"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(r.id)}
|
|
className="text-gray-400 hover:text-rose-600 transition-colors p-1"
|
|
title="删除"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillReminders; |