512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { Header } from '@/components/Header';
|
|
import { ParticipantSelector } from '@/components/ParticipantSelector';
|
|
import { ParticipantManager } from '@/components/ParticipantManager';
|
|
import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap';
|
|
import { AvailabilityHeatmapV2 } from '@/components/AvailabilityHeatmapV2';
|
|
import { ScheduleModal } from '@/components/ScheduleModal';
|
|
import { TimezoneSelector } from '@/components/TimezoneSelector';
|
|
import { Participant, TimeSlot } from '@/types/calendar';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetTrigger,
|
|
} from '@/components/ui/sheet';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import {
|
|
fetchParticipants,
|
|
createParticipant,
|
|
updateParticipant,
|
|
deleteParticipant,
|
|
fetchAvailability,
|
|
syncCalendars,
|
|
clearAllBookings,
|
|
ParticipantAPI,
|
|
} from '@/api/client';
|
|
|
|
const SETTINGS_KEY = 'calendar-settings';
|
|
|
|
// Get user's local timezone
|
|
const getUserTimezone = (): string => {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
} catch {
|
|
return 'America/Toronto';
|
|
}
|
|
};
|
|
|
|
interface SettingsState {
|
|
showPartialAvailability: boolean;
|
|
displayTimezone: string;
|
|
showSecondaryTimezone: boolean;
|
|
secondaryTimezone: string;
|
|
}
|
|
|
|
const defaultSettings: SettingsState = {
|
|
showPartialAvailability: false,
|
|
displayTimezone: getUserTimezone(),
|
|
showSecondaryTimezone: true,
|
|
secondaryTimezone: 'America/Montreal', // Company timezone as default secondary
|
|
};
|
|
|
|
function apiToParticipant(p: ParticipantAPI): Participant {
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
email: p.email,
|
|
timezone: p.timezone,
|
|
icsLink: p.ics_url,
|
|
connected: true,
|
|
};
|
|
}
|
|
|
|
interface IndexProps {
|
|
defaultTab?: string;
|
|
}
|
|
|
|
const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]);
|
|
const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]);
|
|
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [useRedesign, setUseRedesign] = useState(true);
|
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
|
const [weekOffset, setWeekOffset] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
// Sync internal state if prop changes (e.g. browser back button)
|
|
setActiveTab(defaultTab);
|
|
}, [defaultTab]);
|
|
|
|
const handleTabChange = (value: string) => {
|
|
setActiveTab(value);
|
|
navigate(`/${value}`);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
|
if (stored) {
|
|
try {
|
|
setSettings({ ...defaultSettings, ...JSON.parse(stored) });
|
|
} catch (e) {
|
|
console.error('Failed to parse stored settings');
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
}, [settings]);
|
|
|
|
useEffect(() => {
|
|
loadParticipants();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedParticipants.length > 0) {
|
|
loadAvailability();
|
|
} else {
|
|
setAvailabilitySlots([]);
|
|
}
|
|
}, [selectedParticipants, weekOffset]);
|
|
|
|
const loadParticipants = async () => {
|
|
try {
|
|
const data = await fetchParticipants();
|
|
setParticipants(data.map(apiToParticipant));
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Error loading participants',
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const loadAvailability = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const ids = selectedParticipants.map((p) => p.id);
|
|
const slots = await fetchAvailability(ids, weekOffset);
|
|
setAvailabilitySlots(slots);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Error loading availability',
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddParticipant = async (data: { name: string; email: string; timezone: string; icsLink: string }) => {
|
|
try {
|
|
const created = await createParticipant({
|
|
name: data.name,
|
|
email: data.email,
|
|
timezone: data.timezone,
|
|
ics_url: data.icsLink || undefined,
|
|
});
|
|
setParticipants((prev) => [...prev, apiToParticipant(created)]);
|
|
toast({
|
|
title: 'Participant added',
|
|
description: `${data.name} has been added and calendar synced`,
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Error adding participant',
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRemoveParticipant = async (id: string) => {
|
|
try {
|
|
await deleteParticipant(id);
|
|
setParticipants((prev) => prev.filter((p) => p.id !== id));
|
|
setSelectedParticipants((prev) => prev.filter((p) => p.id !== id));
|
|
toast({
|
|
title: 'Participant removed',
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Error removing participant',
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdateParticipant = async (id: string, data: { timezone?: string; ics_url?: string }) => {
|
|
const updated = await updateParticipant(id, data);
|
|
setParticipants((prev) =>
|
|
prev.map((p) => (p.id === id ? apiToParticipant(updated) : p))
|
|
);
|
|
setSelectedParticipants((prev) =>
|
|
prev.map((p) => (p.id === id ? apiToParticipant(updated) : p))
|
|
);
|
|
};
|
|
|
|
const handleSyncCalendars = async () => {
|
|
setIsSyncing(true);
|
|
try {
|
|
await syncCalendars();
|
|
if (selectedParticipants.length > 0) {
|
|
await loadAvailability();
|
|
}
|
|
toast({
|
|
title: 'Calendars synced',
|
|
description: 'All calendars have been refreshed',
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Error syncing calendars',
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsSyncing(false);
|
|
}
|
|
};
|
|
|
|
const handleSlotSelect = (slot: TimeSlot) => {
|
|
setSelectedSlot(slot);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleClearBookings = async () => {
|
|
try {
|
|
await clearAllBookings();
|
|
if (selectedParticipants.length > 0) {
|
|
await loadAvailability();
|
|
}
|
|
toast({
|
|
title: 'Bookings cleared',
|
|
description: 'All scheduled meetings have been removed',
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Error clearing bookings',
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="container max-w-5xl mx-auto px-4 py-8">
|
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
|
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2 bg-muted p-1 rounded-xl">
|
|
<TabsTrigger
|
|
value="participants"
|
|
className="flex items-center gap-2 rounded-lg data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm transition-all"
|
|
>
|
|
<Users className="w-4 h-4" />
|
|
People
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="schedule"
|
|
className="flex items-center gap-2 rounded-lg data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm transition-all"
|
|
>
|
|
<CalendarDays className="w-4 h-4" />
|
|
Schedule
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="participants" className="animate-fade-in focus-visible:outline-none">
|
|
<div className="text-center mb-6">
|
|
<h2 className="text-3xl font-bold text-foreground mb-2">
|
|
Manage People
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
Add team members with their calendar ICS links
|
|
</p>
|
|
</div>
|
|
|
|
<ParticipantManager
|
|
participants={participants}
|
|
onAddParticipant={handleAddParticipant}
|
|
onRemoveParticipant={handleRemoveParticipant}
|
|
onUpdateParticipant={handleUpdateParticipant}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="schedule" className="animate-fade-in">
|
|
<div className="space-y-8">
|
|
<div className="text-center relative">
|
|
<div className="absolute right-0 top-0 flex items-center gap-2">
|
|
<TimezoneSelector
|
|
value={settings.displayTimezone}
|
|
onChange={(tz) => setSettings((prev) => ({ ...prev, displayTimezone: tz }))}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleSyncCalendars}
|
|
disabled={isSyncing}
|
|
>
|
|
<RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="icon">
|
|
<Settings className="w-5 h-5" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="right" className="w-[300px] sm:w-[350px]">
|
|
<div className="space-y-6 py-4">
|
|
<div className="space-y-2">
|
|
<h4 className="font-semibold text-lg tracking-tight">Settings</h4>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configure your calendar preferences.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between gap-4 pb-4 border-b border-border">
|
|
<Label htmlFor="use-redesign" className="text-sm cursor-pointer font-medium text-primary">
|
|
Try New Design
|
|
</Label>
|
|
<Switch
|
|
id="use-redesign"
|
|
checked={useRedesign}
|
|
onCheckedChange={setUseRedesign}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="partial-availability" className="text-sm font-medium cursor-pointer">
|
|
Partial Availability
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Show slots where some are busy
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="partial-availability"
|
|
checked={settings.showPartialAvailability}
|
|
onCheckedChange={(checked) =>
|
|
setSettings((prev) => ({ ...prev, showPartialAvailability: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-4 space-y-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<Label htmlFor="secondary-timezone" className="text-sm cursor-pointer">
|
|
Show secondary timezone
|
|
</Label>
|
|
<Switch
|
|
id="secondary-timezone"
|
|
checked={settings.showSecondaryTimezone}
|
|
onCheckedChange={(checked) =>
|
|
setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
{settings.showSecondaryTimezone && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground block">
|
|
Secondary timezone
|
|
</Label>
|
|
<TimezoneSelector
|
|
value={settings.secondaryTimezone}
|
|
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-4 mt-8">
|
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
|
Danger Zone
|
|
</Label>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive-foreground hover:bg-destructive border-destructive/30"
|
|
>
|
|
Clear All Bookings
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will remove all scheduled meetings. This cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleClearBookings}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Clear All
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
<h2 className="text-3xl font-bold text-foreground mb-2">
|
|
Schedule a Meeting
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
Find the perfect time that works for everyone
|
|
</p>
|
|
</div>
|
|
|
|
{participants.length === 0 ? (
|
|
<div className="bg-card rounded-xl shadow-card p-8 text-center">
|
|
<Users className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
|
<h3 className="text-lg font-medium text-foreground mb-2">No participants yet</h3>
|
|
<p className="text-muted-foreground">
|
|
Add people in the People tab to start scheduling.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="bg-card rounded-xl shadow-card p-6">
|
|
<h3 className="text-lg font-semibold text-foreground mb-4">
|
|
Who's joining?
|
|
</h3>
|
|
<ParticipantSelector
|
|
participants={participants}
|
|
selectedParticipants={selectedParticipants}
|
|
onSelectionChange={setSelectedParticipants}
|
|
/>
|
|
</div>
|
|
|
|
{useRedesign ? (
|
|
<AvailabilityHeatmapV2
|
|
slots={availabilitySlots}
|
|
selectedParticipants={selectedParticipants}
|
|
onSlotSelect={handleSlotSelect}
|
|
showPartialAvailability={settings.showPartialAvailability}
|
|
isLoading={isLoading}
|
|
weekOffset={weekOffset}
|
|
onWeekOffsetChange={setWeekOffset}
|
|
displayTimezone={settings.displayTimezone}
|
|
showSecondaryTimezone={settings.showSecondaryTimezone}
|
|
secondaryTimezone={settings.secondaryTimezone}
|
|
/>
|
|
) : (
|
|
<AvailabilityHeatmap
|
|
slots={availabilitySlots}
|
|
selectedParticipants={selectedParticipants}
|
|
onSlotSelect={handleSlotSelect}
|
|
showPartialAvailability={settings.showPartialAvailability}
|
|
isLoading={isLoading}
|
|
weekOffset={weekOffset}
|
|
onWeekOffsetChange={setWeekOffset}
|
|
displayTimezone={settings.displayTimezone}
|
|
showSecondaryTimezone={settings.showSecondaryTimezone}
|
|
secondaryTimezone={settings.secondaryTimezone}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</main >
|
|
|
|
<ScheduleModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => {
|
|
setIsModalOpen(false);
|
|
setSelectedSlot(null);
|
|
}}
|
|
slot={selectedSlot}
|
|
participants={selectedParticipants}
|
|
displayTimezone={settings.displayTimezone}
|
|
onSuccess={loadAvailability}
|
|
/>
|
|
</div >
|
|
);
|
|
};
|
|
|
|
export default Index;
|