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>
171 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|