Files
vip-coordinator/frontend/src/components/DriverChatModal.tsx
kyle a4d360aae9 feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps
Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:36:51 +01:00

171 lines
5.8 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { X, Send, Loader2 } from 'lucide-react';
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
import { useFormattedDate } from '@/hooks/useFormattedDate';
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 { formatDateTime } = useFormattedDate();
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();
}
};
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'
}`}>
{formatDateTime(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>
);
}