1. Invite auto-sends at read_only: now stages connection with permission selector (Read Only / Create Modify / Full Access) before sending 2. Shared calendars missing from event create dropdown: members with create_modify+ permission now see shared calendars in calendar picker 3. Shared calendar category not showing for owner: owner's shared calendars now appear under SHARED CALENDARS section with "Owner" badge 4. Event creation not updating calendar: handlePanelClose now invalidates calendar-events query to ensure FullCalendar refreshes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
import { useState, FormEvent, useCallback } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import api, { getErrorMessage } from '@/lib/api';
|
|
import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogClose,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Select } from '@/components/ui/select';
|
|
import { useConnections } from '@/hooks/useConnections';
|
|
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
|
import CalendarMemberSearch from './CalendarMemberSearch';
|
|
import CalendarMemberList from './CalendarMemberList';
|
|
|
|
interface CalendarFormProps {
|
|
calendar: Calendar | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const colorSwatches = [
|
|
'#3b82f6', '#ef4444', '#f97316', '#eab308',
|
|
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
|
|
];
|
|
|
|
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = useState(calendar?.name || '');
|
|
const [color, setColor] = useState(calendar?.color || '#3b82f6');
|
|
const [isShared, setIsShared] = useState(calendar?.is_shared ?? false);
|
|
|
|
const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null);
|
|
|
|
const { connections } = useConnections();
|
|
const { invite, isInviting, updateMember, removeMember } = useSharedCalendars();
|
|
|
|
const membersQuery = useQuery({
|
|
queryKey: ['calendar-members', calendar?.id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<CalendarMemberInfo[]>(
|
|
`/shared-calendars/${calendar!.id}/members`
|
|
);
|
|
return data;
|
|
},
|
|
enabled: !!calendar?.is_shared,
|
|
});
|
|
const members = membersQuery.data ?? [];
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (calendar) {
|
|
const { data } = await api.put(`/calendars/${calendar.id}`, { name, color });
|
|
return data;
|
|
} else {
|
|
const { data } = await api.post('/calendars', { name, color });
|
|
return data;
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.success(calendar ? 'Calendar updated' : 'Calendar created');
|
|
onClose();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to save calendar'));
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async () => {
|
|
await api.delete(`/calendars/${calendar?.id}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.success('Calendar deleted');
|
|
onClose();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(getErrorMessage(error, 'Failed to delete calendar'));
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
e.preventDefault();
|
|
if (!name.trim()) return;
|
|
mutation.mutate();
|
|
};
|
|
|
|
const handleSelectConnection = useCallback((conn: Connection) => {
|
|
setPendingInvite({ conn, permission: 'read_only' });
|
|
}, []);
|
|
|
|
const handleSendInvite = async () => {
|
|
if (!calendar || !pendingInvite) return;
|
|
await invite({
|
|
calendarId: calendar.id,
|
|
connectionId: pendingInvite.conn.id,
|
|
permission: pendingInvite.permission,
|
|
canAddOthers: false,
|
|
});
|
|
setPendingInvite(null);
|
|
};
|
|
|
|
const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => {
|
|
if (!calendar) return;
|
|
await updateMember({ calendarId: calendar.id, memberId, permission });
|
|
};
|
|
|
|
const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => {
|
|
if (!calendar) return;
|
|
await updateMember({ calendarId: calendar.id, memberId, canAddOthers });
|
|
};
|
|
|
|
const handleRemoveMember = async (memberId: number) => {
|
|
if (!calendar) return;
|
|
await removeMember({ calendarId: calendar.id, memberId });
|
|
};
|
|
|
|
const canDelete = calendar && !calendar.is_default && !calendar.is_system;
|
|
const showSharing = calendar && !calendar.is_system;
|
|
|
|
return (
|
|
<Dialog open={true} onOpenChange={onClose}>
|
|
<DialogContent className={isShared && showSharing ? 'sm:max-w-lg' : undefined}>
|
|
<DialogClose onClick={onClose} />
|
|
<DialogHeader>
|
|
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cal-name" required>Name</Label>
|
|
<Input
|
|
id="cal-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Calendar name"
|
|
required
|
|
disabled={calendar?.is_system}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Color</Label>
|
|
<div className="flex gap-2">
|
|
{colorSwatches.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => setColor(c)}
|
|
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
|
style={{
|
|
backgroundColor: c,
|
|
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
|
|
boxShadow: color === c ? `0 0 0 2px ${c}40` : 'none',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{showSharing && (
|
|
<>
|
|
<div className="flex items-center justify-between py-3 border-t border-border">
|
|
<Label className="mb-0">Share this calendar</Label>
|
|
<Switch
|
|
checked={isShared}
|
|
onCheckedChange={setIsShared}
|
|
/>
|
|
</div>
|
|
|
|
{isShared && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="mb-0">Members</Label>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
You (Owner)
|
|
</span>
|
|
</div>
|
|
|
|
{pendingInvite ? (
|
|
<div className="rounded-md border border-border bg-card-elevated p-3 space-y-2 animate-fade-in">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-foreground">
|
|
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingInvite(null)}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={pendingInvite.permission}
|
|
onChange={(e) => setPendingInvite((prev) => prev ? { ...prev, permission: e.target.value as CalendarPermission } : null)}
|
|
className="text-xs flex-1"
|
|
>
|
|
<option value="read_only">Read Only</option>
|
|
<option value="create_modify">Create / Modify</option>
|
|
<option value="full_access">Full Access</option>
|
|
</Select>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleSendInvite}
|
|
disabled={isInviting}
|
|
>
|
|
{isInviting ? 'Sending...' : 'Send Invite'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<CalendarMemberSearch
|
|
connections={connections}
|
|
existingMembers={members}
|
|
onSelect={handleSelectConnection}
|
|
isLoading={isInviting}
|
|
/>
|
|
)}
|
|
|
|
<CalendarMemberList
|
|
members={members}
|
|
isLoading={membersQuery.isLoading}
|
|
isOwner={true}
|
|
onUpdatePermission={handleUpdatePermission}
|
|
onUpdateCanAddOthers={handleUpdateCanAddOthers}
|
|
onRemove={handleRemoveMember}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
{canDelete && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => deleteMutation.mutate()}
|
|
disabled={deleteMutation.isPending}
|
|
className="mr-auto"
|
|
>
|
|
Delete
|
|
</Button>
|
|
)}
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={mutation.isPending}>
|
|
{mutation.isPending ? 'Saving...' : calendar ? 'Update' : 'Create'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|