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:
184
frontend/src/components/DriverChatModal.tsx
Normal file
184
frontend/src/components/DriverChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user