feat(projects): Inspections tab (damage + final, issues, rework loop, photo upload)

This commit is contained in:
Satyam Rastogi
2026-05-29 17:45:35 +05:30
parent 6d2c06f74b
commit 35ff7a2e05
+252 -1
View File
@@ -13,7 +13,7 @@ import {
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone, FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin, Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin,
Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info, Settings, Building2, User as UserIcon, Briefcase, Users, HardHat, Wrench, UserCheck, Wallet Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon, Info, Settings, Building2, User as UserIcon, Briefcase, Users, HardHat, Wrench, UserCheck, Wallet, ShieldCheck
} from 'lucide-react'; } from 'lucide-react';
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer'; import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal'; import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
@@ -74,6 +74,7 @@ const tabs = [
{ id: 'milestones', label: 'Milestones', icon: Milestone }, { id: 'milestones', label: 'Milestones', icon: Milestone },
{ id: 'invoices', label: 'Invoices', icon: DollarSign }, { id: 'invoices', label: 'Invoices', icon: DollarSign },
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle }, { id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
{ id: 'inspections', label: 'Inspections', icon: ShieldCheck },
{ id: 'activity', label: 'Activity', icon: Clock }, { id: 'activity', label: 'Activity', icon: Clock },
{ id: 'docs', label: 'Docs', icon: FolderOpen }, { id: 'docs', label: 'Docs', icon: FolderOpen },
]; ];
@@ -192,6 +193,7 @@ const OwnerProjectDetail = () => {
removeUserCommissionOverride, updateProject, removeUserCommissionOverride, updateProject,
addJobTeamMember, removeJobTeamMember, updateJobTeamMember, addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
advanceLifecycleStage, advanceLifecycleStage,
addInspection, addInspectionIssue, resolveInspectionIssue,
} = useMockStore(); } = useMockStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { can } = usePermissions(); const { can } = usePermissions();
@@ -215,6 +217,11 @@ const OwnerProjectDetail = () => {
const [commissionConfig, setCommissionConfig] = useState(null); const [commissionConfig, setCommissionConfig] = useState(null);
const [commissionSettingsOpen, setCommissionSettingsOpen] = useState(false); const [commissionSettingsOpen, setCommissionSettingsOpen] = useState(false);
// ── Inspections state ─────────────────────────────────────────────────────
// issuePhotoMap: { [issueId]: [{id, url, caption}] } — session-only local photos
const [issuePhotoMap, setIssuePhotoMap] = useState({});
const photoInputRefs = useRef({});
// ── Docs state ──────────────────────────────────────────────────────────── // ── Docs state ────────────────────────────────────────────────────────────
const [docs, setDocs] = useState(() => seedDocsMock(projectId)); const [docs, setDocs] = useState(() => seedDocsMock(projectId));
const [viewDoc, setViewDoc] = useState(null); // doc object being viewed const [viewDoc, setViewDoc] = useState(null); // doc object being viewed
@@ -1792,6 +1799,250 @@ const OwnerProjectDetail = () => {
</div> </div>
)} )}
{/* INSPECTIONS TAB */}
{activeTab === 'inspections' && (() => {
const inspections = project.inspections || [];
const isOwnerAdmin = ['OWNER', 'ADMIN'].includes(user?.role);
// Find latest final inspection for re-inspection scheduling
const finalInspections = inspections.filter(ins => ins.kind === 'final');
const latestFinal = finalInspections.length > 0
? finalInspections.reduce((latest, ins) => (ins.round || 1) > (latest.round || 1) ? ins : latest, finalInspections[0])
: null;
const showScheduleReinspection = isOwnerAdmin && latestFinal && latestFinal.result === 'fail';
const handlePhotoAttach = (issueId, file) => {
if (!file) return;
const url = URL.createObjectURL(file);
const photo = { id: Date.now(), url, caption: file.name };
setIssuePhotoMap(prev => ({
...prev,
[issueId]: [...(prev[issueId] || []), photo],
}));
};
return (
<div className="space-y-6">
{/* Header + Schedule Re-inspection */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ShieldCheck className={NEON_BLUE} size={20} />
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Inspections</h3>
<span className="text-xs font-bold bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/5 px-2 py-0.5 rounded-full text-zinc-500 dark:text-zinc-400">
{inspections.length}
</span>
</div>
{showScheduleReinspection && (
<button
onClick={() => {
addInspection(project.id, {
kind: 'final',
round: (latestFinal.round || 1) + 1,
inspectorName: '',
inspectorContact: { phone: '', email: '' },
inspectionDate: '',
feedbackNote: '',
issues: [],
});
}}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-[#fda913] border border-amber-500/30 text-xs font-bold transition"
>
<Plus size={14} /> Schedule Re-inspection (Round {(latestFinal.round || 1) + 1})
</button>
)}
</div>
{inspections.length === 0 ? (
<NeoCard>
<div className="text-center py-16">
<ShieldCheck size={48} className="mx-auto mb-4 text-zinc-400 dark:text-zinc-600" />
<p className="font-bold text-lg text-zinc-900 dark:text-white">No inspections recorded yet.</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">Damage verifications and final inspections will appear here.</p>
</div>
</NeoCard>
) : (
inspections.map((inspection) => {
const isDamage = inspection.kind === 'damage';
const kindLabel = isDamage ? 'Damage Verification' : 'Final Inspection';
const resultBadge = inspection.result === 'pass'
? 'text-emerald-600 dark:text-[#39ff14] bg-emerald-50 dark:bg-[#39ff14]/10 border border-emerald-200 dark:border-[#39ff14]/20'
: inspection.result === 'fail'
? 'text-red-600 dark:text-[#ff003c] bg-red-50 dark:bg-[#ff003c]/10 border border-red-200 dark:border-[#ff003c]/20'
: 'text-zinc-500 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10';
const resultLabel = inspection.result === 'pass' ? 'Pass' : inspection.result === 'fail' ? 'Fail' : 'Pending';
const spotlightColor = isDamage ? 'rgba(253, 169, 19, 0.08)' : 'rgba(0, 240, 255, 0.08)';
const headerColor = isDamage ? NEON_GOLD : NEON_BLUE;
const issues = inspection.issues || [];
return (
<NeoCard key={inspection.id} spotlightColor={spotlightColor}>
{/* Inspection header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3 mb-4 pb-4 border-b border-zinc-200 dark:border-white/5">
<div className="flex items-start gap-3">
<ShieldCheck className={headerColor} size={18} />
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-zinc-900 dark:text-white text-sm">{kindLabel}</span>
{(inspection.round || 1) > 1 && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Round {inspection.round}
</span>
)}
<span className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-widest ${resultBadge}`}>
{resultLabel}
</span>
</div>
<div className="mt-2 space-y-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{inspection.inspectorName && (
<p>
<span className="font-semibold text-zinc-700 dark:text-zinc-300">Inspector:</span>{' '}
{inspection.inspectorName}
{isDamage && inspection.inspectorRole && `${inspection.inspectorRole}`}
</p>
)}
{!isDamage && inspection.inspectorContact && (
<p className="flex flex-wrap gap-x-3">
{inspection.inspectorContact.phone && (
<span className="flex items-center gap-1"><Phone size={11} /> {inspection.inspectorContact.phone}</span>
)}
{inspection.inspectorContact.email && (
<span className="flex items-center gap-1"><Mail size={11} /> {inspection.inspectorContact.email}</span>
)}
</p>
)}
{inspection.inspectionDate && (
<p><span className="font-semibold text-zinc-700 dark:text-zinc-300">Date:</span>{' '}<span className="font-mono">{inspection.inspectionDate}</span></p>
)}
</div>
{inspection.feedbackNote && (
<p className="mt-2 text-xs text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/5 rounded-lg px-3 py-2">
{inspection.feedbackNote}
</p>
)}
</div>
</div>
{/* Add Issue button — OWNER/ADMIN only */}
{isOwnerAdmin && (
<button
onClick={() => setCreateModalConfig({
title: 'Add Issue to Inspection',
icon: AlertCircle,
iconColor: 'text-orange-500',
iconBg: 'bg-orange-500/10',
submitLabel: 'Add Issue',
submitColor: 'bg-orange-500/20 text-orange-500 border-orange-500/30',
fields: [
{ name: 'description', label: 'Issue Description', type: 'text', required: true },
{ name: 'note', label: 'Note', type: 'textarea', required: false },
],
onSubmit: (data) => addInspectionIssue(project.id, inspection.id, {
description: data.description,
note: data.note || '',
photos: [],
}),
})}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-orange-500/10 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400 border border-orange-500/30 text-xs font-bold transition shrink-0"
>
<Plus size={13} /> Add Issue
</button>
)}
</div>
{/* Issues list */}
{issues.length > 0 ? (
<div className="space-y-3">
<p className="text-[10px] font-mono font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">
Issues ({issues.length})
</p>
{issues.map((issue) => {
const localPhotos = issuePhotoMap[issue.id] || [];
const allPhotos = [...(issue.photos || []), ...localPhotos];
const isOpen = issue.status === 'open';
return (
<div key={issue.id} className="rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/[0.06] p-4">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-zinc-900 dark:text-white">{issue.description}</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-widest border ${isOpen
? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-500/10 border-orange-200 dark:border-orange-500/20'
: 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20'
}`}>
{isOpen ? 'Open' : 'Fixed'}
</span>
</div>
{issue.note && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">{issue.note}</p>
)}
</div>
{/* Actions: Mark Fixed + Attach Photo — OWNER/ADMIN only */}
{isOwnerAdmin && (
<div className="flex items-center gap-2 shrink-0">
{isOpen && (
<button
onClick={() => resolveInspectionIssue(project.id, inspection.id, issue.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-xs font-bold transition"
>
<CheckCircle size={12} /> Mark Fixed
</button>
)}
{/* Hidden file input for photo attach */}
<button
onClick={() => {
if (!photoInputRefs.current[issue.id]) return;
photoInputRefs.current[issue.id].click();
}}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-white/10 text-xs font-bold transition"
>
<Camera size={12} /> Photo
</button>
<input
type="file"
accept="image/*"
className="hidden"
ref={(el) => { photoInputRefs.current[issue.id] = el; }}
onChange={(e) => {
handlePhotoAttach(issue.id, e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
)}
</div>
{/* Photo thumbnails */}
{allPhotos.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{allPhotos.map((photo) => (
<div key={photo.id} className="relative group w-16 h-16 rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10 bg-zinc-100 dark:bg-zinc-800 shrink-0">
<img
src={photo.url}
alt={photo.caption || 'Issue photo'}
className="w-full h-full object-cover"
/>
{photo.caption && (
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-1">
<span className="text-[9px] text-white leading-tight line-clamp-2">{photo.caption}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400 italic">No issues recorded for this inspection.</p>
)}
</NeoCard>
);
})
)}
</div>
);
})()}
{/* ACTIVITY TAB */} {/* ACTIVITY TAB */}
{/* ── DOCS TAB ── */} {/* ── DOCS TAB ── */}
{activeTab === 'docs' && ( {activeTab === 'docs' && (