Files
common-availability/frontend/src/components/ScheduleModal.tsx
2026-02-05 13:45:32 -05:00

258 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { TimeSlot, Participant } from '@/types/calendar';
import { scheduleMeeting } from '@/api/client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from '@/hooks/use-toast';
import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react';
const DURATION_OPTIONS = [
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 45, label: '45 minutes' },
{ value: 60, label: '1 hour' },
{ value: 90, label: '1 hour 30 minutes' },
{ value: 120, label: '2 hours' },
{ value: 150, label: '2 hours 30 minutes' },
];
interface ScheduleModalProps {
isOpen: boolean;
onClose: () => void;
slot: TimeSlot | null;
participants: Participant[];
displayTimezone?: string;
onSuccess?: () => void;
}
// Get user's local timezone
const getUserTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'America/Toronto';
}
};
// Format timezone for display (e.g., "America/Toronto" -> "EST")
const getTimezoneAbbrev = (timezone: string): string => {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(now);
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
} catch {
return '';
}
};
export const ScheduleModal = ({
isOpen,
onClose,
slot,
participants,
displayTimezone = getUserTimezone(),
onSuccess,
}: ScheduleModalProps) => {
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [duration, setDuration] = useState(60); // default 1 hour
const [isSubmitting, setIsSubmitting] = useState(false);
const formatHour = (hour: number) => {
return `${hour.toString().padStart(2, '0')}:00`;
};
const formatTime = (hour: number, minutes: number) => {
const totalMinutes = hour * 60 + minutes;
const h = Math.floor(totalMinutes / 60) % 24;
const m = totalMinutes % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
};
const getEndTime = () => {
if (!slot) return '';
return formatTime(slot.hour, duration);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
};
const isTooSoon = () => {
if (!slot) return false;
// Use UTC to match backend timezone
const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`);
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return startDateTime < twoHoursFromNow;
};
const tooSoon = isTooSoon();
const handleSubmit = async () => {
if (!title.trim()) {
toast({
title: "Please enter a meeting title",
variant: "destructive",
});
return;
}
if (!slot) return;
setIsSubmitting(true);
try {
// Calculate start and end times
// slot.day is YYYY-MM-DD
const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`);
const endDateTime = new Date(startDateTime.getTime() + duration * 60 * 1000);
await scheduleMeeting(
participants.map(p => p.id),
title,
notes,
startDateTime.toISOString(),
endDateTime.toISOString()
);
toast({
title: "Meeting scheduled",
description: "Invitations sent via Email and Zulip",
});
setTitle('');
setNotes('');
onClose();
onSuccess?.();
} catch (error) {
toast({
title: "Scheduling failed",
description: error instanceof Error ? error.message : "Unknown error",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
if (!slot) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md animate-scale-in">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">Schedule Meeting</DialogTitle>
</DialogHeader>
<div className="space-y-6 pt-4">
{/* Time Info */}
<div className="bg-accent/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm">
<Calendar className="w-4 h-4 text-primary" />
<span className="font-medium">{formatDate(slot.day)}</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Clock className="w-4 h-4 text-primary" />
<span><span className="text-primary font-medium">{formatHour(slot.hour)} {getEndTime()}</span> <span className="text-muted-foreground">({getTimezoneAbbrev(displayTimezone)})</span></span>
</div>
<div className="flex items-center gap-3 text-sm">
<Users className="w-4 h-4 text-primary" />
<span>{participants.map(p => p.name.split(' ')[0]).join(', ')}</span>
</div>
</div>
{/* Form */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Duration</Label>
<Select
value={duration.toString()}
onValueChange={(value) => setDuration(Number(value))}
>
<SelectTrigger className="h-12">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Meeting Title</Label>
<Input
id="title"
placeholder="Team Sync"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-12"
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea
id="notes"
placeholder="Add any notes or agenda items..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
{/* Lead Time Warning */}
{tooSoon && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>Meetings must be scheduled at least 2 hours in advance</span>
</div>
)}
{/* Actions */}
<Button
variant="schedule"
className="w-full h-12"
onClick={handleSubmit}
disabled={isSubmitting || tooSoon}
>
{isSubmitting ? (
<span className="animate-pulse">Sending...</span>
) : (
<>
<Send className="w-4 h-4" />
Send Invites
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};