cleanup
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, Circle, Pencil, RefreshCw, Calendar, Trophy } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format, isPast, isToday } from 'date-fns';
|
||||
import { clsx } from 'clsx';
|
||||
import { api, type Chore } from '@/lib/api';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
interface Props {
|
||||
chore: Chore;
|
||||
onEdit: (chore: Chore) => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
'in-progress':'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
done: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
};
|
||||
|
||||
export function ChoreCard({ chore, onEdit }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [completing, setCompleting] = useState(false);
|
||||
const isDone = chore.status === 'done';
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: () => api.post(`/chores/${chore.id}/complete`, { member_id: chore.member_id }),
|
||||
onMutate: () => setCompleting(true),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['chores'] }); },
|
||||
onSettled: () => setCompleting(false),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => api.put(`/chores/${chore.id}`, { status: 'pending' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chores'] }),
|
||||
});
|
||||
|
||||
const dueDateOverdue = chore.due_date && !isDone && isPast(new Date(chore.due_date)) && !isToday(new Date(chore.due_date));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
className={clsx(
|
||||
'group flex items-start gap-4 p-4 rounded-2xl bg-surface border transition-colors duration-150',
|
||||
isDone ? 'border-theme opacity-60' : 'border-theme hover:border-accent/40',
|
||||
)}
|
||||
style={chore.member_color ? { borderLeftColor: chore.member_color, borderLeftWidth: 3 } : {}}
|
||||
>
|
||||
{/* Complete button */}
|
||||
<button
|
||||
onClick={() => isDone ? resetMutation.mutate() : completeMutation.mutate()}
|
||||
disabled={completing}
|
||||
className="mt-0.5 shrink-0 transition-transform duration-150 hover:scale-110"
|
||||
aria-label={isDone ? 'Mark as pending' : 'Mark as done'}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 size={22} className="text-green-500" />
|
||||
) : (
|
||||
<Circle size={22} className="text-muted hover:text-accent transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className={clsx(
|
||||
'font-semibold text-primary leading-snug',
|
||||
isDone && 'line-through text-muted'
|
||||
)}>
|
||||
{chore.title}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onEdit(chore)}
|
||||
className="shrink-0 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-secondary hover:bg-surface-raised hover:text-accent transition-all"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{chore.description && (
|
||||
<p className="text-sm text-secondary mt-0.5 line-clamp-1">{chore.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
{/* Status badge */}
|
||||
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium capitalize', STATUS_STYLES[chore.status] ?? STATUS_STYLES.pending)}>
|
||||
{chore.status}
|
||||
</span>
|
||||
|
||||
{/* Recurrence */}
|
||||
{chore.recurrence !== 'none' && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<RefreshCw size={11} /> {chore.recurrence}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Due date */}
|
||||
{chore.due_date && (
|
||||
<span className={clsx('flex items-center gap-1 text-xs', dueDateOverdue ? 'text-red-500 font-medium' : 'text-muted')}>
|
||||
<Calendar size={11} />
|
||||
{isToday(new Date(chore.due_date)) ? 'Today' : format(new Date(chore.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Completion streak */}
|
||||
{chore.completion_count > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<Trophy size={11} className="text-amber-500" /> {chore.completion_count}×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member avatar */}
|
||||
{chore.member_name && chore.member_color && (
|
||||
<div className="shrink-0">
|
||||
<Avatar name={chore.member_name} color={chore.member_color} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type Chore } from '@/lib/api';
|
||||
import { useMembers } from '@/hooks/useMembers';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
const RECURRENCE = [
|
||||
{ value: 'none', label: 'One-time' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
chore?: Chore | null;
|
||||
}
|
||||
|
||||
export function ChoreModal({ open, onClose, chore }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const { data: members = [] } = useMembers();
|
||||
const isEdit = !!chore;
|
||||
|
||||
const blank = () => ({ title: '', description: '', member_id: '', recurrence: 'none', due_date: '' });
|
||||
const [form, setForm] = useState(blank);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (chore) {
|
||||
setForm({
|
||||
title: chore.title,
|
||||
description: chore.description ?? '',
|
||||
member_id: chore.member_id ? String(chore.member_id) : '',
|
||||
recurrence: chore.recurrence,
|
||||
due_date: chore.due_date ?? '',
|
||||
});
|
||||
} else {
|
||||
setForm(blank());
|
||||
}
|
||||
setError('');
|
||||
}, [open, chore]);
|
||||
|
||||
const set = (k: keyof typeof form, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: object) =>
|
||||
isEdit
|
||||
? api.put(`/chores/${chore!.id}`, body).then((r) => r.data)
|
||||
: api.post('/chores', body).then((r) => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['chores'] }); onClose(); },
|
||||
onError: () => setError('Failed to save. Please try again.'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/chores/${chore!.id}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['chores'] }); onClose(); },
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.title.trim()) { setError('Title is required'); return; }
|
||||
saveMutation.mutate({
|
||||
title: form.title.trim(),
|
||||
description: form.description || null,
|
||||
member_id: form.member_id ? Number(form.member_id) : null,
|
||||
recurrence: form.recurrence,
|
||||
due_date: form.due_date || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={isEdit ? 'Edit Chore' : 'New Chore'}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
placeholder="What needs to be done?"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Assigned To"
|
||||
value={form.member_id}
|
||||
onChange={(e) => set('member_id', e.target.value)}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
label="Repeats"
|
||||
value={form.recurrence}
|
||||
onChange={(e) => set('recurrence', e.target.value)}
|
||||
>
|
||||
{RECURRENCE.map((r) => (
|
||||
<option key={r.value} value={r.value}>{r.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary block mb-1.5">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.due_date}
|
||||
onChange={(e) => set('due_date', e.target.value)}
|
||||
className="w-full rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
value={form.description}
|
||||
onChange={(e) => set('description', e.target.value)}
|
||||
placeholder="Optional details…"
|
||||
rows={2}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
loading={deleteMutation.isPending}
|
||||
className="text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={saveMutation.isPending}>
|
||||
{isEdit ? 'Save Changes' : 'Add Chore'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user