247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import dynamic from 'next/dynamic';
|
|
import { api } from '@/lib/api';
|
|
import { FormModal } from '@/components/ui/form-modal';
|
|
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useToast } from '@/components/ui/toast';
|
|
import { LocationPickerModal } from '@/components/maps/location-picker-modal';
|
|
|
|
const LeafletMap = dynamic(() => import('@/components/maps/leaflet-map').then((m) => ({ default: m.LeafletMap })), { ssr: false });
|
|
|
|
interface TicketDetailModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onUpdated: () => void;
|
|
ticketId: string | null;
|
|
}
|
|
|
|
export function TicketDetailModal({ open, onClose, onUpdated, ticketId }: TicketDetailModalProps) {
|
|
const { toast } = useToast();
|
|
const [ticket, setTicket] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [status, setStatus] = useState('');
|
|
const [priority, setPriority] = useState('');
|
|
const [comment, setComment] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [resolving, setResolving] = useState(false);
|
|
const [selectedLat, setSelectedLat] = useState<number | null>(null);
|
|
const [selectedLng, setSelectedLng] = useState<number | null>(null);
|
|
const [showLocationPicker, setShowLocationPicker] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open || !ticketId) return;
|
|
setLoading(true);
|
|
api.get(`/tickets/${ticketId}`).then((r) => {
|
|
const t = r.data.data;
|
|
setTicket(t);
|
|
setStatus(t.status);
|
|
setPriority(t.priority);
|
|
if (t.client?.latitude != null && t.client?.longitude != null) {
|
|
setSelectedLat(t.client.latitude);
|
|
setSelectedLng(t.client.longitude);
|
|
}
|
|
}).catch(() => toast('Failed to load ticket', 'error'))
|
|
.finally(() => setLoading(false));
|
|
}, [open, ticketId, toast]);
|
|
|
|
async function handleUpdate() {
|
|
if (!ticket) return;
|
|
setSaving(true);
|
|
try {
|
|
const updates: any = {};
|
|
if (status !== ticket.status) updates.status = status;
|
|
if (priority !== ticket.priority) updates.priority = priority;
|
|
if (comment.trim()) {
|
|
const existingDesc = ticket.description || '';
|
|
const timestamp = new Date().toLocaleString();
|
|
const newDesc = existingDesc
|
|
? `${existingDesc}\n\n--- Comment (${timestamp}) ---\n${comment.trim()}`
|
|
: `--- Comment (${timestamp}) ---\n${comment.trim()}`;
|
|
updates.description = newDesc;
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
toast('No changes to save', 'info');
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
|
|
await api.patch(`/tickets/${ticketId}`, updates);
|
|
toast('Ticket updated', 'success');
|
|
setComment('');
|
|
onUpdated();
|
|
onClose();
|
|
} catch (err: any) {
|
|
toast(err.response?.data?.error || 'Failed to update', 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleResolve() {
|
|
setResolving(true);
|
|
try {
|
|
const body: any = {};
|
|
if (selectedLat !== null && selectedLng !== null) {
|
|
body.latitude = selectedLat;
|
|
body.longitude = selectedLng;
|
|
}
|
|
await api.patch(`/tickets/${ticketId}/resolve`, body);
|
|
toast('Ticket resolved', 'success');
|
|
onUpdated();
|
|
onClose();
|
|
} catch (err: any) {
|
|
toast(err.response?.data?.error || 'Failed to resolve', 'error');
|
|
} finally {
|
|
setResolving(false);
|
|
}
|
|
}
|
|
|
|
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<FormModal open={open} onClose={onClose} title={ticket?.title || 'Loading...'} wide>
|
|
{loading ? (
|
|
<div className="py-8 text-center text-surface-400">Loading ticket details...</div>
|
|
) : ticket ? (
|
|
<div className="space-y-5">
|
|
{/* Ticket info */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Type</span>
|
|
<div className="mt-1"><Badge label={ticket.type} /></div>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Status</span>
|
|
<div className="mt-1"><Badge label={ticket.status} variant={statusBadgeVariant(ticket.status)} /></div>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Client</span>
|
|
<p className="mt-1 text-sm text-surface-800 dark:text-surface-200">
|
|
{ticket.client ? `${ticket.client.firstName} ${ticket.client.lastName}` : '—'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Assignee</span>
|
|
<p className="mt-1 text-sm text-surface-800 dark:text-surface-200">
|
|
{ticket.assignee ? `${ticket.assignee.firstName} ${ticket.assignee.lastName}` : 'Unassigned'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Created</span>
|
|
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{new Date(ticket.createdAt).toLocaleString()}</p>
|
|
</div>
|
|
{ticket.resolvedAt && (
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Resolved</span>
|
|
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{new Date(ticket.resolvedAt).toLocaleString()}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notes/Comments history */}
|
|
{ticket.description && (
|
|
<div>
|
|
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Notes & Comments</span>
|
|
<div className="mt-2 bg-surface-50 dark:bg-surface-800 rounded-lg p-4 text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap max-h-40 overflow-y-auto">
|
|
{ticket.description}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Update form — only if not resolved/cancelled */}
|
|
{ticket.status !== 'resolved' && ticket.status !== 'cancelled' && (
|
|
<>
|
|
<div className="border-t border-surface-200 pt-5">
|
|
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Update Ticket</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label htmlFor="t-status" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Status</label>
|
|
<select id="t-status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
|
|
<option value="open">Open</option>
|
|
<option value="in_progress">In Progress</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="t-priority" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Priority</label>
|
|
<select id="t-priority" value={priority} onChange={(e) => setPriority(e.target.value)} className={inputClass}>
|
|
<option value="low">Low</option>
|
|
<option value="normal">Normal</option>
|
|
<option value="high">High</option>
|
|
<option value="urgent">Urgent</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="t-comment" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Add Comment</label>
|
|
<textarea id="t-comment" rows={3} value={comment} onChange={(e) => setComment(e.target.value)}
|
|
className={`${inputClass} resize-none`} placeholder="Add a note or comment..." />
|
|
</div>
|
|
|
|
{/* Location picker for installation tickets */}
|
|
{ticket.type === 'installation' && ticket.clientId && (
|
|
<div className="border-t border-surface-200 pt-5">
|
|
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Client Location</h3>
|
|
{selectedLat !== null && selectedLng !== null ? (
|
|
<div className="mb-3">
|
|
<LeafletMap latitude={selectedLat} longitude={selectedLng} height="200px" zoom={16} />
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-surface-500">
|
|
{selectedLat !== null && selectedLng !== null
|
|
? `${selectedLat.toFixed(6)}, ${selectedLng.toFixed(6)}`
|
|
: 'No location pinned yet'}
|
|
</span>
|
|
<Button size="sm" variant="secondary" onClick={() => setShowLocationPicker(true)}>
|
|
{selectedLat !== null ? 'Update Pin' : 'Pin Location'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between pt-2">
|
|
<Button variant="secondary" onClick={handleResolve} loading={resolving}>
|
|
Resolve Ticket
|
|
</Button>
|
|
<div className="flex gap-3">
|
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
|
<Button onClick={handleUpdate} loading={saving}>Save Changes</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Already resolved */}
|
|
{(ticket.status === 'resolved' || ticket.status === 'cancelled') && (
|
|
<div className="flex justify-end pt-2">
|
|
<Button variant="secondary" onClick={onClose}>Close</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
<LocationPickerModal
|
|
open={showLocationPicker}
|
|
onClose={() => setShowLocationPicker(false)}
|
|
onConfirm={(lat, lng) => {
|
|
setSelectedLat(lat);
|
|
setSelectedLng(lng);
|
|
setShowLocationPicker(false);
|
|
}}
|
|
initialLatitude={selectedLat}
|
|
initialLongitude={selectedLng}
|
|
title="Pin Installation Location"
|
|
description="Pin the client's installation location on the map."
|
|
/>
|
|
</FormModal>
|
|
);
|
|
}
|