initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:37 +08:00
commit 3f5bf0e118
88 changed files with 10997 additions and 0 deletions

View 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>
);
}