cleanup
This commit is contained in:
40
apps/client/src/components/ui/Select.tsx
Normal file
40
apps/client/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { forwardRef, SelectHTMLAttributes } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ label, error, hint, className, id, children, ...props }, ref) => {
|
||||||
|
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-secondary">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
className={clsx(
|
||||||
|
'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 focus:border-transparent',
|
||||||
|
'transition-colors duration-150 cursor-pointer',
|
||||||
|
error && 'border-red-400 focus:ring-red-400',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
{hint && !error && <p className="text-xs text-muted">{hint}</p>}
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Select.displayName = 'Select';
|
||||||
39
apps/client/src/components/ui/Textarea.tsx
Normal file
39
apps/client/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { forwardRef, TextareaHTMLAttributes } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ label, error, hint, className, id, ...props }, ref) => {
|
||||||
|
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-secondary">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
rows={3}
|
||||||
|
className={clsx(
|
||||||
|
'w-full rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary',
|
||||||
|
'placeholder:text-muted focus:outline-none focus:ring-2 ring-accent focus:border-transparent',
|
||||||
|
'transition-colors duration-150 resize-none',
|
||||||
|
error && 'border-red-400 focus:ring-red-400',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{hint && !error && <p className="text-xs text-muted">{hint}</p>}
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
109
apps/client/src/features/calendar/CalendarGrid.tsx
Normal file
109
apps/client/src/features/calendar/CalendarGrid.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
|
eachDayOfInterval, isSameMonth, isToday, isSameDay, format,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import type { CalendarEvent, Member } from '@/lib/api';
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
month: Date;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
members: Member[];
|
||||||
|
onDayClick: (date: Date) => void;
|
||||||
|
onEventClick: (event: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventColor(event: CalendarEvent, members: Member[]): string {
|
||||||
|
if (event.color) return event.color;
|
||||||
|
if (event.member_id) {
|
||||||
|
return members.find((m) => m.id === event.member_id)?.color ?? '#6366f1';
|
||||||
|
}
|
||||||
|
return '#6366f1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarGrid({ month, events, members, onDayClick, onEventClick }: Props) {
|
||||||
|
const days = useMemo(() => {
|
||||||
|
const start = startOfWeek(startOfMonth(month));
|
||||||
|
const end = endOfWeek(endOfMonth(month));
|
||||||
|
return eachDayOfInterval({ start, end });
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
function dayEvents(day: Date) {
|
||||||
|
return events.filter((e) => {
|
||||||
|
const start = new Date(e.start_at);
|
||||||
|
const end = new Date(e.end_at);
|
||||||
|
// All-day or events that touch this day
|
||||||
|
return isSameDay(start, day) || (start <= day && end >= day);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Weekday headers */}
|
||||||
|
<div className="grid grid-cols-7 border-b border-theme">
|
||||||
|
{WEEKDAYS.map((d) => (
|
||||||
|
<div key={d} className="py-2 text-center text-xs font-semibold text-muted uppercase tracking-wide">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day grid */}
|
||||||
|
<div className="grid grid-cols-7 flex-1" style={{ gridTemplateRows: `repeat(${days.length / 7}, 1fr)` }}>
|
||||||
|
{days.map((day, i) => {
|
||||||
|
const de = dayEvents(day);
|
||||||
|
const inMonth = isSameMonth(day, month);
|
||||||
|
const today = isToday(day);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
whileHover={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||||
|
onClick={() => onDayClick(day)}
|
||||||
|
className={clsx(
|
||||||
|
'relative min-h-[80px] border-b border-r border-theme p-1.5 cursor-pointer',
|
||||||
|
'transition-colors duration-100',
|
||||||
|
!inMonth && 'opacity-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Date number */}
|
||||||
|
<div className="flex justify-end mb-1">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'h-6 w-6 flex items-center justify-center rounded-full text-xs font-semibold',
|
||||||
|
today
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'text-primary hover:bg-surface-raised'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, 'd')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event chips */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{de.slice(0, 3).map((ev) => (
|
||||||
|
<button
|
||||||
|
key={ev.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEventClick(ev); }}
|
||||||
|
className="w-full text-left px-1.5 py-0.5 rounded text-xs font-medium text-white truncate leading-5"
|
||||||
|
style={{ backgroundColor: eventColor(ev, members) }}
|
||||||
|
>
|
||||||
|
{ev.all_day ? '' : `${format(new Date(ev.start_at), 'h:mm')} `}{ev.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{de.length > 3 && (
|
||||||
|
<span className="text-xs text-muted px-1">+{de.length - 3} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
apps/client/src/features/calendar/DayEventsModal.tsx
Normal file
87
apps/client/src/features/calendar/DayEventsModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Plus, Clock, RefreshCw } from 'lucide-react';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { CalendarEvent, Member } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
date: Date | null;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
members: Member[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (event: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventColor(event: CalendarEvent, members: Member[]): string {
|
||||||
|
if (event.color) return event.color;
|
||||||
|
if (event.member_id) {
|
||||||
|
return members.find((m) => m.id === event.member_id)?.color ?? '#6366f1';
|
||||||
|
}
|
||||||
|
return '#6366f1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DayEventsModal({ open, onClose, date, events, members, onAdd, onEdit }: Props) {
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={format(date, 'EEEE, MMMM d, yyyy')}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-secondary text-sm text-center py-4">No events this day.</p>
|
||||||
|
) : (
|
||||||
|
events.map((ev) => {
|
||||||
|
const color = eventColor(ev, members);
|
||||||
|
const member = members.find((m) => m.id === ev.member_id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ev.id}
|
||||||
|
onClick={() => { onClose(); onEdit(ev); }}
|
||||||
|
className="w-full text-left flex items-start gap-3 p-3 rounded-xl border border-theme hover:border-accent/40 hover:bg-surface-raised transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="mt-1 h-3 w-3 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-primary text-sm truncate">{ev.title}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
{!ev.all_day && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<Clock size={11} />
|
||||||
|
{format(new Date(ev.start_at), 'h:mm a')} – {format(new Date(ev.end_at), 'h:mm a')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ev.all_day && <span className="text-xs text-muted">All day</span>}
|
||||||
|
{ev.recurrence && ev.recurrence !== 'none' && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted capitalize">
|
||||||
|
<RefreshCw size={10} /> {ev.recurrence}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{member && (
|
||||||
|
<span className="text-xs font-medium" style={{ color: member.color }}>
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ev.description && (
|
||||||
|
<p className="text-xs text-muted mt-1 truncate">{ev.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-theme">
|
||||||
|
<Button onClick={() => { onClose(); onAdd(); }} className="w-full">
|
||||||
|
<Plus size={16} /> Add Event for {format(date, 'MMM d')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
apps/client/src/features/calendar/EventModal.tsx
Normal file
241
apps/client/src/features/calendar/EventModal.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api, type CalendarEvent } 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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
event?: CalendarEvent | null;
|
||||||
|
defaultDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeLocal(iso: string) {
|
||||||
|
return iso ? iso.slice(0, 16) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toISO(local: string) {
|
||||||
|
return local ? new Date(local).toISOString() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECURRENCE_OPTIONS = [
|
||||||
|
{ value: 'none', label: 'Does not repeat' },
|
||||||
|
{ value: 'daily', label: 'Daily' },
|
||||||
|
{ value: 'weekly', label: 'Weekly' },
|
||||||
|
{ value: 'monthly', label: 'Monthly' },
|
||||||
|
{ value: 'yearly', label: 'Yearly' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EventModal({ open, onClose, event, defaultDate }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: members = [] } = useMembers();
|
||||||
|
const isEdit = !!event;
|
||||||
|
|
||||||
|
const blank = () => {
|
||||||
|
const d = defaultDate ?? new Date();
|
||||||
|
const start = new Date(d);
|
||||||
|
start.setHours(9, 0, 0, 0);
|
||||||
|
const end = new Date(d);
|
||||||
|
end.setHours(10, 0, 0, 0);
|
||||||
|
return {
|
||||||
|
title: '', description: '', all_day: false, recurrence: 'none',
|
||||||
|
member_id: '', color: '',
|
||||||
|
start_at: format(start, "yyyy-MM-dd'T'HH:mm"),
|
||||||
|
end_at: format(end, "yyyy-MM-dd'T'HH:mm"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form, setForm] = useState(blank);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (event) {
|
||||||
|
setForm({
|
||||||
|
title: event.title,
|
||||||
|
description: event.description ?? '',
|
||||||
|
all_day: !!event.all_day,
|
||||||
|
recurrence: event.recurrence ?? 'none',
|
||||||
|
member_id: event.member_id ? String(event.member_id) : '',
|
||||||
|
color: event.color ?? '',
|
||||||
|
start_at: toDateTimeLocal(event.start_at),
|
||||||
|
end_at: toDateTimeLocal(event.end_at),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm(blank());
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
}, [open, event]);
|
||||||
|
|
||||||
|
const set = (k: keyof typeof form, v: string | boolean) =>
|
||||||
|
setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (body: object) =>
|
||||||
|
isEdit
|
||||||
|
? api.put(`/events/${event!.id}`, body).then((r) => r.data)
|
||||||
|
: api.post('/events', body).then((r) => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['events'] }); onClose(); },
|
||||||
|
onError: () => setError('Failed to save event. Please try again.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/events/${event!.id}`),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['events'] }); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.title.trim()) { setError('Title is required'); return; }
|
||||||
|
if (!form.start_at || !form.end_at) { setError('Start and end times are required'); return; }
|
||||||
|
saveMutation.mutate({
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description || null,
|
||||||
|
start_at: toISO(form.start_at),
|
||||||
|
end_at: toISO(form.end_at),
|
||||||
|
all_day: form.all_day,
|
||||||
|
recurrence: form.recurrence === 'none' ? null : form.recurrence,
|
||||||
|
member_id: form.member_id ? Number(form.member_id) : null,
|
||||||
|
color: form.color || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive chip color: use selected member's color, or user-picked color, or accent
|
||||||
|
const chipColor = (() => {
|
||||||
|
if (form.color) return form.color;
|
||||||
|
if (form.member_id) return members.find((m) => m.id === Number(form.member_id))?.color ?? '';
|
||||||
|
return '';
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={isEdit ? 'Edit Event' : 'New Event'} size="lg">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => set('title', e.target.value)}
|
||||||
|
placeholder="Event title"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-secondary block mb-1.5">Start</label>
|
||||||
|
<input
|
||||||
|
type={form.all_day ? 'date' : 'datetime-local'}
|
||||||
|
value={form.all_day ? form.start_at.slice(0, 10) : form.start_at}
|
||||||
|
onChange={(e) => set('start_at', 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>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-secondary block mb-1.5">End</label>
|
||||||
|
<input
|
||||||
|
type={form.all_day ? 'date' : 'datetime-local'}
|
||||||
|
value={form.all_day ? form.end_at.slice(0, 10) : form.end_at}
|
||||||
|
onChange={(e) => set('end_at', 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<div
|
||||||
|
onClick={() => set('all_day', !form.all_day)}
|
||||||
|
className={`relative h-5 w-9 rounded-full transition-colors duration-200 ${form.all_day ? 'bg-accent' : 'bg-border'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all duration-200 ${form.all_day ? 'left-[18px]' : 'left-0.5'}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-secondary">All day</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Family Member"
|
||||||
|
value={form.member_id}
|
||||||
|
onChange={(e) => set('member_id', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">No one assigned</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_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set('description', e.target.value)}
|
||||||
|
placeholder="Optional notes…"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-secondary block mb-1.5">
|
||||||
|
Event Color <span className="text-muted font-normal">(overrides member color)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={chipColor || '#6366f1'}
|
||||||
|
onChange={(e) => set('color', e.target.value)}
|
||||||
|
className="h-9 w-12 cursor-pointer rounded-lg border border-theme bg-transparent"
|
||||||
|
/>
|
||||||
|
{chipColor && (
|
||||||
|
<span
|
||||||
|
className="px-3 py-1 rounded-full text-xs font-medium text-white"
|
||||||
|
style={{ backgroundColor: chipColor }}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{form.color && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-muted hover:text-secondary"
|
||||||
|
onClick={() => set('color', '')}
|
||||||
|
>
|
||||||
|
Clear override
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 Event'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/client/src/features/chores/ChoreCard.tsx
Normal file
125
apps/client/src/features/chores/ChoreCard.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
apps/client/src/features/chores/ChoreModal.tsx
Normal file
146
apps/client/src/features/chores/ChoreModal.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/client/src/features/shopping/ShoppingItemRow.tsx
Normal file
152
apps/client/src/features/shopping/ShoppingItemRow.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Trash2, UserCircle } from 'lucide-react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { api, type ShoppingItem, type Member } from '@/lib/api';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ShoppingItem;
|
||||||
|
members: Member[];
|
||||||
|
listId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShoppingItemRow({ item, members, listId }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [showAssign, setShowAssign] = useState(false);
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: () => api.patch(`/shopping/items/${item.id}`, { checked: !item.checked }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping-items', listId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/shopping/items/${item.id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping-items', listId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
mutationFn: (member_id: number | null) =>
|
||||||
|
api.patch(`/shopping/items/${item.id}`, { member_id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['shopping-items', listId] });
|
||||||
|
setShowAssign(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignedMember = members.find((m) => m.id === item.member_id);
|
||||||
|
const isChecked = !!item.checked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.li
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={clsx(
|
||||||
|
'group flex items-center gap-3 px-4 py-3 border-b border-theme last:border-b-0',
|
||||||
|
'hover:bg-surface-raised transition-colors duration-100',
|
||||||
|
)}>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMutation.mutate()}
|
||||||
|
className="shrink-0 h-5 w-5 rounded border-2 flex items-center justify-center transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
borderColor: isChecked ? 'var(--color-accent)' : 'var(--color-border)',
|
||||||
|
backgroundColor: isChecked ? 'var(--color-accent)' : 'transparent',
|
||||||
|
}}
|
||||||
|
aria-label={isChecked ? 'Uncheck' : 'Check'}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<motion.svg
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="h-3 w-3 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 12 10"
|
||||||
|
>
|
||||||
|
<path d="M1 5l3.5 3.5L11 1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</motion.svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Name + quantity */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={clsx(
|
||||||
|
'text-sm transition-all duration-300',
|
||||||
|
isChecked ? 'line-through text-muted' : 'text-primary font-medium'
|
||||||
|
)}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{item.quantity && (
|
||||||
|
<span className="ml-2 text-xs text-muted">{item.quantity}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign button */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssign((s) => !s)}
|
||||||
|
className={clsx(
|
||||||
|
'p-1 rounded-lg transition-colors',
|
||||||
|
assignedMember
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted hover:text-secondary'
|
||||||
|
)}
|
||||||
|
aria-label="Assign to member"
|
||||||
|
>
|
||||||
|
{assignedMember ? (
|
||||||
|
<Avatar name={assignedMember.name} color={assignedMember.color} size="xs" />
|
||||||
|
) : (
|
||||||
|
<UserCircle size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAssign && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
className="absolute right-0 top-8 z-20 bg-surface border border-theme rounded-xl shadow-xl p-1.5 min-w-[140px]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => assignMutation.mutate(null)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<UserCircle size={14} /> Unassign
|
||||||
|
</button>
|
||||||
|
{members.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => assignMutation.mutate(m.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<Avatar name={m.name} color={m.color} size="xs" />
|
||||||
|
<span className="text-primary">{m.name}</span>
|
||||||
|
{item.member_id === m.id && <span className="ml-auto text-accent text-xs">✓</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
className="shrink-0 p-1 rounded-lg opacity-0 group-hover:opacity-100 text-muted hover:text-red-500 transition-all"
|
||||||
|
aria-label="Delete item"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backdrop to close assign menu */}
|
||||||
|
{showAssign && (
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setShowAssign(false)} />
|
||||||
|
)}
|
||||||
|
</motion.li>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/client/src/hooks/useMembers.ts
Normal file
16
apps/client/src/hooks/useMembers.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api, type Member } from '@/lib/api';
|
||||||
|
|
||||||
|
export function useMembers() {
|
||||||
|
return useQuery<Member[]>({
|
||||||
|
queryKey: ['members'],
|
||||||
|
queryFn: () => api.get('/members').then((r) => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a lookup map of id → member for O(1) access */
|
||||||
|
export function useMembersMap() {
|
||||||
|
const { data = [] } = useMembers();
|
||||||
|
return Object.fromEntries(data.map((m) => [m.id, m])) as Record<number, Member>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,224 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
addMonths, subMonths, format, isSameDay,
|
||||||
|
startOfMonth, endOfMonth,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { ChevronLeft, ChevronRight, Plus, CalendarDays } from 'lucide-react';
|
||||||
|
import { api, type CalendarEvent } from '@/lib/api';
|
||||||
|
import { useMembers } from '@/hooks/useMembers';
|
||||||
|
import { CalendarGrid } from '@/features/calendar/CalendarGrid';
|
||||||
|
import { EventModal } from '@/features/calendar/EventModal';
|
||||||
|
import { DayEventsModal } from '@/features/calendar/DayEventsModal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Calendar</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
const [month, setMonth] = useState(new Date());
|
||||||
|
const [direction, setDirection] = useState(0);
|
||||||
|
|
||||||
|
const [dayModal, setDayModal] = useState<Date | null>(null);
|
||||||
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
const [addDate, setAddDate] = useState<Date | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: members = [] } = useMembers();
|
||||||
|
|
||||||
|
const { data: events = [] } = useQuery<CalendarEvent[]>({
|
||||||
|
queryKey: ['events', format(month, 'yyyy-MM')],
|
||||||
|
queryFn: () => {
|
||||||
|
const start = startOfMonth(month).toISOString();
|
||||||
|
const end = endOfMonth(month).toISOString();
|
||||||
|
return api.get('/events', { params: { start, end } }).then((r) => r.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigate(dir: number) {
|
||||||
|
setDirection(dir);
|
||||||
|
setMonth((m) => dir > 0 ? addMonths(m, 1) : subMonths(m, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(date: Date) {
|
||||||
|
setDayModal(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddFromDay() {
|
||||||
|
setAddDate(dayModal);
|
||||||
|
setAddOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddNew() {
|
||||||
|
setAddDate(null);
|
||||||
|
setAddOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayEvents = useMemo(() => {
|
||||||
|
if (!dayModal) return [];
|
||||||
|
return events.filter((e) => isSameDay(new Date(e.start_at), dayModal));
|
||||||
|
}, [dayModal, events]);
|
||||||
|
|
||||||
|
// Upcoming events strip (next 5 from today)
|
||||||
|
const upcoming = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return [...events]
|
||||||
|
.filter((e) => new Date(e.start_at) >= now)
|
||||||
|
.sort((a, b) => +new Date(a.start_at) - +new Date(b.start_at))
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
enter: (d: number) => ({ x: d > 0 ? 40 : -40, opacity: 0 }),
|
||||||
|
center: { x: 0, opacity: 1 },
|
||||||
|
exit: (d: number) => ({ x: d > 0 ? -40 : 40, opacity: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarDays size={22} className="text-accent" />
|
||||||
|
<AnimatePresence mode="wait" custom={direction}>
|
||||||
|
<motion.h1
|
||||||
|
key={format(month, 'yyyy-MM')}
|
||||||
|
custom={direction}
|
||||||
|
variants={variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="text-xl font-bold text-primary w-44"
|
||||||
|
>
|
||||||
|
{format(month, 'MMMM yyyy')}
|
||||||
|
</motion.h1>
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="p-1.5 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDirection(0); setMonth(new Date()); }}
|
||||||
|
className="px-3 py-1 rounded-lg text-xs font-medium text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
className="p-1.5 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Member legend */}
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: m.color }} />
|
||||||
|
<span className="text-xs text-secondary">{m.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleAddNew}>
|
||||||
|
<Plus size={15} /> Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main: grid + sidebar ──────────────────────────────────── */}
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<AnimatePresence mode="wait" custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={format(month, 'yyyy-MM')}
|
||||||
|
custom={direction}
|
||||||
|
variants={variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
month={month}
|
||||||
|
events={events}
|
||||||
|
members={members}
|
||||||
|
onDayClick={handleDayClick}
|
||||||
|
onEventClick={(ev) => setEditEvent(ev)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming sidebar */}
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<aside className="hidden lg:flex flex-col w-64 shrink-0 border-l border-theme bg-surface overflow-y-auto">
|
||||||
|
<div className="px-4 py-3 border-b border-theme">
|
||||||
|
<p className="text-xs font-semibold text-muted uppercase tracking-wide">Upcoming</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex flex-col gap-2">
|
||||||
|
{upcoming.map((ev) => {
|
||||||
|
const color = ev.color ?? members.find((m) => m.id === ev.member_id)?.color ?? '#6366f1';
|
||||||
|
const member = members.find((m) => m.id === ev.member_id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ev.id}
|
||||||
|
onClick={() => setEditEvent(ev)}
|
||||||
|
className="w-full text-left p-3 rounded-xl border border-theme hover:border-accent/40 hover:bg-surface-raised transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-1.5 h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-primary truncate">{ev.title}</p>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
{format(new Date(ev.start_at), 'MMM d')}
|
||||||
|
{!ev.all_day && ` · ${format(new Date(ev.start_at), 'h:mm a')}`}
|
||||||
|
</p>
|
||||||
|
{member && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<Avatar name={member.name} color={member.color} size="xs" />
|
||||||
|
<span className="text-xs text-secondary">{member.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Modals ─────────────────────────────────────────────────── */}
|
||||||
|
<DayEventsModal
|
||||||
|
open={!!dayModal}
|
||||||
|
onClose={() => setDayModal(null)}
|
||||||
|
date={dayModal}
|
||||||
|
events={dayEvents}
|
||||||
|
members={members}
|
||||||
|
onAdd={handleAddFromDay}
|
||||||
|
onEdit={(ev) => { setDayModal(null); setEditEvent(ev); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventModal
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => { setAddOpen(false); setAddDate(null); }}
|
||||||
|
defaultDate={addDate ?? undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventModal
|
||||||
|
open={!!editEvent}
|
||||||
|
onClose={() => setEditEvent(null)}
|
||||||
|
event={editEvent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,155 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Plus, CheckSquare, ListFilter } from 'lucide-react';
|
||||||
|
import { api, type Chore } from '@/lib/api';
|
||||||
|
import { useMembers } from '@/hooks/useMembers';
|
||||||
|
import { ChoreCard } from '@/features/chores/ChoreCard';
|
||||||
|
import { ChoreModal } from '@/features/chores/ChoreModal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
type Filter = 'all' | 'pending' | 'done' | number; // number = member_id
|
||||||
|
|
||||||
export default function ChoresPage() {
|
export default function ChoresPage() {
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Chores</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
const [filter, setFilter] = useState<Filter>('pending');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editChore, setEditChore] = useState<Chore | null>(null);
|
||||||
|
|
||||||
|
const { data: members = [] } = useMembers();
|
||||||
|
|
||||||
|
const { data: chores = [], isLoading } = useQuery<Chore[]>({
|
||||||
|
queryKey: ['chores'],
|
||||||
|
queryFn: () => api.get('/chores').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === 'all') return chores;
|
||||||
|
if (filter === 'pending') return chores.filter((c) => c.status !== 'done');
|
||||||
|
if (filter === 'done') return chores.filter((c) => c.status === 'done');
|
||||||
|
return chores.filter((c) => c.member_id === filter);
|
||||||
|
}, [chores, filter]);
|
||||||
|
|
||||||
|
const pendingCount = chores.filter((c) => c.status !== 'done').length;
|
||||||
|
const doneCount = chores.filter((c) => c.status === 'done').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Header ──────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckSquare size={22} className="text-accent" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-primary leading-tight">Chores</h1>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{pendingCount} pending · {doneCount} done
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setModalOpen(true)}>
|
||||||
|
<Plus size={15} /> Add Chore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter bar ──────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-2 px-6 py-3 border-b border-theme bg-surface shrink-0 overflow-x-auto">
|
||||||
|
<ListFilter size={15} className="text-muted shrink-0" />
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: 'pending', label: 'Pending' },
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'done', label: 'Done' },
|
||||||
|
] as { key: Filter; label: string }[]
|
||||||
|
).map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={String(key)}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-full text-sm font-medium transition-colors whitespace-nowrap',
|
||||||
|
filter === key
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'text-secondary hover:bg-surface-raised hover:text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{members.length > 0 && <span className="text-muted text-xs mx-1">|</span>}
|
||||||
|
{members.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setFilter(filter === m.id ? 'all' : m.id)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all whitespace-nowrap',
|
||||||
|
filter === m.id
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-secondary hover:bg-surface-raised hover:text-primary'
|
||||||
|
)}
|
||||||
|
style={filter === m.id ? { backgroundColor: m.color } : {}}
|
||||||
|
>
|
||||||
|
<Avatar name={m.name} color={m.color} size="xs" />
|
||||||
|
{m.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── List ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-20 rounded-2xl bg-surface-raised animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex flex-col items-center justify-center py-20 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-5xl mb-4">
|
||||||
|
{filter === 'done' ? '🎉' : '✅'}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold text-primary mb-1">
|
||||||
|
{filter === 'done' ? 'Nothing completed yet' : 'All caught up!'}
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary text-sm mb-6">
|
||||||
|
{filter === 'done'
|
||||||
|
? 'Complete a chore and it will appear here.'
|
||||||
|
: 'No chores match this filter.'}
|
||||||
|
</p>
|
||||||
|
{filter === 'pending' && (
|
||||||
|
<Button onClick={() => setModalOpen(true)}>
|
||||||
|
<Plus size={16} /> Add First Chore
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filtered.map((chore) => (
|
||||||
|
<ChoreCard
|
||||||
|
key={chore.id}
|
||||||
|
chore={chore}
|
||||||
|
onEdit={(c) => setEditChore(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Modals ──────────────────────────────────────────────── */}
|
||||||
|
<ChoreModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<ChoreModal
|
||||||
|
open={!!editChore}
|
||||||
|
onClose={() => setEditChore(null)}
|
||||||
|
chore={editChore}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,292 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Plus, ShoppingCart, Trash2, ListPlus, X } from 'lucide-react';
|
||||||
|
import { api, type ShoppingList, type ShoppingItem } from '@/lib/api';
|
||||||
|
import { useMembers } from '@/hooks/useMembers';
|
||||||
|
import { ShoppingItemRow } from '@/features/shopping/ShoppingItemRow';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
export default function ShoppingPage() {
|
export default function ShoppingPage() {
|
||||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Shopping</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
const qc = useQueryClient();
|
||||||
|
const { data: members = [] } = useMembers();
|
||||||
|
|
||||||
|
const [activeListId, setActiveListId] = useState<number | null>(null);
|
||||||
|
const [newItemText, setNewItemText] = useState('');
|
||||||
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
|
const [newListOpen, setNewListOpen] = useState(false);
|
||||||
|
const [newListName, setNewListName] = useState('');
|
||||||
|
const [deleteListOpen, setDeleteListOpen] = useState(false);
|
||||||
|
|
||||||
|
const addInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Lists ──────────────────────────────────────────────────────
|
||||||
|
const { data: lists = [] } = useQuery<ShoppingList[]>({
|
||||||
|
queryKey: ['shopping-lists'],
|
||||||
|
queryFn: () => api.get('/shopping/lists').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-select first list
|
||||||
|
useEffect(() => {
|
||||||
|
if (lists.length > 0 && activeListId === null) {
|
||||||
|
setActiveListId(lists[0].id);
|
||||||
|
}
|
||||||
|
}, [lists, activeListId]);
|
||||||
|
|
||||||
|
const createListMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => api.post('/shopping/lists', { name }).then((r) => r.data),
|
||||||
|
onSuccess: (list: ShoppingList) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['shopping-lists'] });
|
||||||
|
setActiveListId(list.id);
|
||||||
|
setNewListOpen(false);
|
||||||
|
setNewListName('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteListMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/shopping/lists/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['shopping-lists'] });
|
||||||
|
setActiveListId(null);
|
||||||
|
setDeleteListOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Items ──────────────────────────────────────────────────────
|
||||||
|
const { data: items = [], isLoading: itemsLoading } = useQuery<ShoppingItem[]>({
|
||||||
|
queryKey: ['shopping-items', activeListId],
|
||||||
|
queryFn: () =>
|
||||||
|
activeListId
|
||||||
|
? api.get(`/shopping/lists/${activeListId}/items`).then((r) => r.data)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
enabled: !!activeListId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addItemMutation = useMutation({
|
||||||
|
mutationFn: (body: { name: string; quantity?: string }) =>
|
||||||
|
api.post(`/shopping/lists/${activeListId}/items`, body).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['shopping-items', activeListId] });
|
||||||
|
setNewItemText('');
|
||||||
|
setNewItemQty('');
|
||||||
|
addInputRef.current?.focus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearCheckedMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/shopping/lists/${activeListId}/items/checked`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping-items', activeListId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
if (!newItemText.trim() || !activeListId) return;
|
||||||
|
addItemMutation.mutate({
|
||||||
|
name: newItemText.trim(),
|
||||||
|
quantity: newItemQty.trim() || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkedCount = items.filter((i) => i.checked).length;
|
||||||
|
const pendingCount = items.length - checkedCount;
|
||||||
|
const currentList = lists.find((l) => l.id === activeListId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── Header ──────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShoppingCart size={22} className="text-accent" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-primary leading-tight">Shopping</h1>
|
||||||
|
{currentList && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{pendingCount} item{pendingCount !== 1 ? 's' : ''} remaining
|
||||||
|
{checkedCount > 0 && ` · ${checkedCount} checked`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{checkedCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clearCheckedMutation.mutate()}
|
||||||
|
loading={clearCheckedMutation.isPending}
|
||||||
|
className="text-muted hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Clear checked
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setNewListOpen(true)}>
|
||||||
|
<ListPlus size={15} /> New List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── List tabs ────────────────────────────────────────────── */}
|
||||||
|
{lists.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 px-6 py-2 border-b border-theme bg-surface shrink-0 overflow-x-auto">
|
||||||
|
{lists.map((list) => (
|
||||||
|
<button
|
||||||
|
key={list.id}
|
||||||
|
onClick={() => setActiveListId(list.id)}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-1.5 rounded-full text-sm font-medium transition-all whitespace-nowrap',
|
||||||
|
activeListId === list.id
|
||||||
|
? 'bg-accent text-white shadow-sm'
|
||||||
|
: 'text-secondary hover:bg-surface-raised hover:text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{list.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Items ────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{lists.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||||
|
<div className="text-5xl mb-4">🛒</div>
|
||||||
|
<p className="text-lg font-semibold text-primary mb-1">No shopping lists yet</p>
|
||||||
|
<p className="text-secondary text-sm mb-6">Create a list to start adding items.</p>
|
||||||
|
<Button onClick={() => setNewListOpen(true)}>
|
||||||
|
<Plus size={16} /> Create List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : itemsLoading ? (
|
||||||
|
<div className="p-6 space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="h-12 rounded-xl bg-surface-raised animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center px-6">
|
||||||
|
<div className="text-4xl mb-3">📝</div>
|
||||||
|
<p className="text-primary font-semibold mb-1">List is empty</p>
|
||||||
|
<p className="text-secondary text-sm">Add items using the field below.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="bg-surface mx-4 my-4 rounded-2xl border border-theme overflow-hidden">
|
||||||
|
{/* Unchecked items first */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{items
|
||||||
|
.filter((i) => !i.checked)
|
||||||
|
.map((item) => (
|
||||||
|
<ShoppingItemRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
members={members}
|
||||||
|
listId={activeListId!}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Checked items section */}
|
||||||
|
{checkedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<li className="px-4 py-2 bg-surface-raised border-t border-theme">
|
||||||
|
<span className="text-xs font-semibold text-muted uppercase tracking-wide">
|
||||||
|
In cart ({checkedCount})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{items
|
||||||
|
.filter((i) => i.checked)
|
||||||
|
.map((item) => (
|
||||||
|
<ShoppingItemRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
members={members}
|
||||||
|
listId={activeListId!}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Quick-add bar (pinned to bottom) ──────────────────────── */}
|
||||||
|
{activeListId && (
|
||||||
|
<div className="shrink-0 bg-surface border-t border-theme px-4 py-3">
|
||||||
|
<div className="flex gap-2 max-w-2xl mx-auto">
|
||||||
|
<input
|
||||||
|
ref={addInputRef}
|
||||||
|
type="text"
|
||||||
|
value={newItemText}
|
||||||
|
onChange={(e) => setNewItemText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddItem()}
|
||||||
|
placeholder="Add item…"
|
||||||
|
className="flex-1 rounded-xl border border-theme bg-surface-raised px-4 py-2.5 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newItemQty}
|
||||||
|
onChange={(e) => setNewItemQty(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddItem()}
|
||||||
|
placeholder="Qty"
|
||||||
|
className="w-20 rounded-xl border border-theme bg-surface-raised px-3 py-2.5 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddItem}
|
||||||
|
disabled={!newItemText.trim()}
|
||||||
|
loading={addItemMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── New list modal ─────────────────────────────────────────── */}
|
||||||
|
<Modal open={newListOpen} onClose={() => setNewListOpen(false)} title="New Shopping List" size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="List Name"
|
||||||
|
value={newListName}
|
||||||
|
onChange={(e) => setNewListName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && newListName.trim() && createListMutation.mutate(newListName.trim())}
|
||||||
|
placeholder="e.g. Groceries, Hardware Store…"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" onClick={() => setNewListOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createListMutation.mutate(newListName.trim())}
|
||||||
|
disabled={!newListName.trim()}
|
||||||
|
loading={createListMutation.isPending}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Delete list confirmation ───────────────────────────────── */}
|
||||||
|
<Modal open={deleteListOpen} onClose={() => setDeleteListOpen(false)} title="Delete List?" size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-secondary text-sm">
|
||||||
|
This will permanently delete <strong className="text-primary">{currentList?.name}</strong> and all
|
||||||
|
its items. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteListOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteListMutation.isPending}
|
||||||
|
onClick={() => activeListId && deleteListMutation.mutate(activeListId)}
|
||||||
|
>
|
||||||
|
Delete List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user