feat: comprehensive update with Signal, Copilot, themes, and PDF features

## Signal Messaging Integration
- Added SignalService for sending messages to drivers via Signal
- SignalMessage model for tracking message history
- Driver chat modal for real-time messaging
- Send schedule via Signal (ICS + PDF attachments)

## AI Copilot
- Natural language interface for VIP Coordinator
- Capabilities: create VIPs, schedule events, assign drivers
- Help and guidance for users
- Floating copilot button in UI

## Theme System
- Dark/light/system theme support
- Color scheme selection (blue, green, purple, orange, red)
- ThemeContext for global state
- AppearanceMenu in header

## PDF Schedule Export
- VIPSchedulePDF component for schedule generation
- PDF settings (header, footer, branding)
- Preview PDF in browser
- Settings stored in database

## Database Migrations
- add_signal_messages: SignalMessage model
- add_pdf_settings: Settings model for PDF config
- add_reminder_tracking: lastReminderSent for events
- make_driver_phone_optional: phone field nullable

## Event Management
- Event status service for automated updates
- IN_PROGRESS/COMPLETED status tracking
- Reminder tracking for notifications

## UI/UX Improvements
- Driver schedule modal
- Improved My Schedule page
- Better error handling and loading states
- Responsive design improvements

## Other Changes
- AGENT_TEAM.md documentation
- Seed data improvements
- Ability factory updates
- Driver profile page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 19:30:41 +01:00
parent 2d842ed294
commit 3b0b1205df
84 changed files with 12330 additions and 2103 deletions

View File

@@ -0,0 +1,184 @@
import { useState, useEffect, useRef } from 'react';
import { X, Send, Loader2 } from 'lucide-react';
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
interface Driver {
id: string;
name: string;
phone: string;
}
interface DriverChatModalProps {
driver: Driver | null;
isOpen: boolean;
onClose: () => void;
}
export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProps) {
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
const sendMessage = useSendMessage();
const markAsRead = useMarkMessagesAsRead();
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Mark messages as read when opening chat
useEffect(() => {
if (isOpen && driver?.id) {
markAsRead.mutate(driver.id);
}
}, [isOpen, driver?.id]);
if (!isOpen || !driver) return null;
const handleSend = async () => {
const trimmedMessage = message.trim();
if (!trimmedMessage || sendMessage.isPending) return;
try {
await sendMessage.mutateAsync({
driverId: driver.id,
content: trimmedMessage,
});
setMessage('');
} catch (error) {
console.error('Failed to send message:', error);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className="bg-card border border-border rounded-lg shadow-xl w-full max-w-md mx-4 flex flex-col"
style={{ height: 'min(600px, 80vh)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex flex-col">
<h3 className="font-semibold text-card-foreground">{driver.name}</h3>
<span className="text-xs text-muted-foreground">{driver.phone}</span>
</div>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-muted transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : messages && messages.length > 0 ? (
<>
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.direction === 'OUTBOUND' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
msg.direction === 'OUTBOUND'
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-muted text-muted-foreground rounded-bl-sm'
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p>
<p className={`text-[10px] mt-1 ${
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
}`}>
{formatTime(msg.timestamp)}
</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-sm">No messages yet. Send a message to start the conversation.</p>
</div>
)}
</div>
{/* Input */}
<div className="p-4 border-t border-border">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
className="flex-1 resize-none bg-muted rounded-xl px-4 py-2.5 text-sm
focus:outline-none focus:ring-2 focus:ring-primary/50
max-h-32 min-h-[42px]"
style={{
height: 'auto',
overflowY: message.split('\n').length > 3 ? 'auto' : 'hidden',
}}
/>
<button
onClick={handleSend}
disabled={!message.trim() || sendMessage.isPending}
className="p-2.5 rounded-full bg-primary text-primary-foreground
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-primary/90 transition-colors"
>
{sendMessage.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
{sendMessage.isError && (
<p className="mt-2 text-xs text-destructive">
Failed to send message. Please try again.
</p>
)}
</div>
</div>
</div>
);
}