initial: standalone repo from monorepo split
This commit is contained in:
246
src/components/modals/ticket-detail-modal.tsx
Normal file
246
src/components/modals/ticket-detail-modal.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user