350 lines
12 KiB
TypeScript
350 lines
12 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 { ScheduleModal } from '@/components/ScheduleModal';
|
|
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 { 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,
|
|
ParticipantAPI,
|
|
} from '@/api/client';
|
|
|
|
const SETTINGS_KEY = 'calendar-settings';
|
|
|
|
interface SettingsState {
|
|
showPartialAvailability: boolean;
|
|
}
|
|
|
|
const defaultSettings: SettingsState = {
|
|
showPartialAvailability: false,
|
|
};
|
|
|
|
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 [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
|
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]);
|
|
|
|
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);
|
|
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);
|
|
};
|
|
|
|
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">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleSyncCalendars}
|
|
disabled={isSyncing}
|
|
>
|
|
<RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon">
|
|
<Settings className="w-5 h-5" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72" align="end">
|
|
<div className="space-y-4">
|
|
<h4 className="font-medium">Settings</h4>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<Label htmlFor="partial-availability" className="text-sm cursor-pointer">
|
|
Show partial availability
|
|
</Label>
|
|
<Switch
|
|
id="partial-availability"
|
|
checked={settings.showPartialAvailability}
|
|
onCheckedChange={(checked) =>
|
|
setSettings((prev) => ({ ...prev, showPartialAvailability: checked }))
|
|
}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
When enabled, shows time slots where only some participants are available.
|
|
</p>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</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>
|
|
|
|
<AvailabilityHeatmap
|
|
slots={availabilitySlots}
|
|
selectedParticipants={selectedParticipants}
|
|
onSlotSelect={handleSlotSelect}
|
|
showPartialAvailability={settings.showPartialAvailability}
|
|
isLoading={isLoading}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</main>
|
|
|
|
<ScheduleModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => {
|
|
setIsModalOpen(false);
|
|
setSelectedSlot(null);
|
|
}}
|
|
slot={selectedSlot}
|
|
participants={selectedParticipants}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Index;
|