import React, { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { MapPin, Calendar, User, Plus, CheckCircle2, AlertCircle, RefreshCw, Search, MessageSquare, Edit2, FolderKanban, FileText, ShieldCheck, Building, Send, Lock, } from 'lucide-react'; import { getProjects, getProjectSiteVisits, getSiteVisit, createSiteVisit, updateSiteVisit, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { useRbac } from '../auth/rbac'; import { ApprovalStatusBadge } from '../components/ui/StatusBadge'; import { SiteVisit, ApprovalStatus } from '../types'; import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS, } from '../lib/formatters'; const siteVisitSchema = z.object({ inspectorName: z.string().min(2, 'Inspector name required'), visitedAt: z.string().min(4, 'Visit date is required'), findings: z.string().min(5, 'Field findings and observations required'), status: z.coerce.number().optional(), }); type SiteVisitFormValues = z.infer; export const SiteVisitsPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); const [selectedProjectId, setSelectedProjectId] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); // Modals const [isVisitModalOpen, setIsVisitModalOpen] = useState(false); const [editingVisit, setEditingVisit] = useState(null); const [selectedVisitForDetail, setSelectedVisitForDetail] = useState(null); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); // Queries const { data: projects = [], isLoading: loadingProjects } = useQuery({ queryKey: ['projects'], queryFn: () => getProjects(), }); const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id; const { data: siteVisits = [], isLoading: loadingVisits, refetch: refetchVisits, isFetching: fetchingVisits, } = useQuery({ queryKey: ['siteVisits', effectiveProjectId], queryFn: () => (effectiveProjectId ? getProjectSiteVisits(effectiveProjectId) : Promise.resolve([])), enabled: !!effectiveProjectId, }); // Forms const visitForm = useForm({ resolver: zodResolver(siteVisitSchema), defaultValues: { inspectorName: '', visitedAt: new Date().toISOString().split('T')[0], findings: '', status: ApprovalStatus.Pending, }, }); // Mutations const createVisitMutation = useMutation({ mutationFn: (vals: SiteVisitFormValues) => { if (!effectiveProjectId) throw new Error('No project selected'); return createSiteVisit({ projectId: effectiveProjectId, inspectorName: vals.inspectorName, findings: vals.findings, visitedAt: vals.visitedAt, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['siteVisits'] }); setIsVisitModalOpen(false); visitForm.reset(); success('Site Visit Logged', 'Field inspection recorded with inspector findings.'); }, onError: (err: unknown) => toastError('Logging Failed', err), }); const updateVisitMutation = useMutation({ mutationFn: (vals: SiteVisitFormValues) => { if (!editingVisit) throw new Error('No visit selected'); if (Number(editingVisit.status) === ApprovalStatus.Approved) { throw new Error('Approved site visits are verified and immutable. No modifications are permitted.'); } return updateSiteVisit({ siteVisitId: editingVisit.id, inspectorName: vals.inspectorName, findings: vals.findings, status: vals.status ?? ApprovalStatus.Pending, visitedAt: vals.visitedAt, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['siteVisits'] }); setIsVisitModalOpen(false); setEditingVisit(null); visitForm.reset(); success('Site Visit Updated', 'Inspection report and verification status updated.'); }, onError: (err: unknown) => toastError('Update Failed', err), }); // Filtered & Sorted Visits const filteredVisits = useMemo(() => { return siteVisits.filter((v) => { const matchSearch = !searchTerm.trim() || (v.inspectorName && v.inspectorName.toLowerCase().includes(searchTerm.toLowerCase())) || (v.findings && v.findings.toLowerCase().includes(searchTerm.toLowerCase())); const matchStatus = statusFilter === 'all' || (statusFilter === 'approved' && v.status === ApprovalStatus.Approved) || (statusFilter === 'pending' && v.status !== ApprovalStatus.Approved); return matchSearch && matchStatus; }); }, [siteVisits, searchTerm, statusFilter]); const { sortedData: sortedVisits, sortColumn: visitSortCol, sortDirection: visitSortDir, handleSort: handleVisitSort } = useTableSort(filteredVisits); // Exports const visitExportCols: ExportColumn[] = [ { header: 'Inspector Name', key: 'inspectorName' }, { header: 'Visit Date', key: 'visitedAt', format: (val) => formatDate(val) }, { header: 'Findings', key: 'findings' }, { header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending') }, ]; const handleOpenEdit = async (v: SiteVisit) => { if (Number(v.status) === ApprovalStatus.Approved) { toastError('Record Immutable', 'Approved site visits are verified and cannot be edited.'); return; } try { const fresh = await getSiteVisit(v.id); setEditingVisit(fresh); visitForm.reset({ inspectorName: fresh.inspectorName || '', visitedAt: fresh.visitedAt ? fresh.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0], findings: fresh.findings || '', status: fresh.status ?? ApprovalStatus.Pending, }); setIsVisitModalOpen(true); } catch { setEditingVisit(v); visitForm.reset({ inspectorName: v.inspectorName || '', visitedAt: v.visitedAt ? v.visitedAt.split('T')[0] : new Date().toISOString().split('T')[0], findings: v.findings || '', status: v.status ?? ApprovalStatus.Pending, }); setIsVisitModalOpen(true); } }; return (
{/* Header Banner */}

Site Visits & Field Verification

Log physical inspection reports, record field findings, and capture project feedback.

{/* KPI Cards */}
Total Site Visits

{siteVisits.length}

Field inspections logged

Approved Reports

{siteVisits.filter((v) => v.status === ApprovalStatus.Approved).length}

Verified by project governance

Under Review

{siteVisits.filter((v) => v.status === ApprovalStatus.Pending || v.status === ApprovalStatus.Review).length}

Awaiting sign-off

Audited Projects

{projects.length}

Under physical monitoring

{/* Site Visits Register View */}
{/* Search */}
setSearchTerm(e.target.value)} className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-emerald-500 shadow-xs" />
{/* Status Filter */}
{/* Project Selector */}
{/* Actions: Refresh, Export, Log Site Visit */}
{canAdd('site_visits') && ( )}
Inspector Name Inspection Date Field Observations & Findings Verification Status {loadingVisits ? ( ) : sortedVisits.length === 0 ? ( ) : ( sortedVisits.map((v) => ( )) )}
Actions
Loading site visit logs...
No site visits recorded for this project yet.
{v.inspectorName || 'Field Officer'}
{formatDate(v.visitedAt)}
{v.findings || 'No findings recorded.'} {Number(v.status) === ApprovalStatus.Approved ? ( ) : ( )}
{/* Modal: Create/Edit Site Visit */} setIsVisitModalOpen(false)} title={editingVisit ? 'Edit Site Visit Report' : 'Log Project Site Visit'} size="md" >
(editingVisit ? updateVisitMutation.mutate(d) : createVisitMutation.mutate(d)))} className="space-y-4" >