242 lines
8.5 KiB
TypeScript
242 lines
8.5 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|