feat(projects): Inspections tab (damage + final, issues, rework loop, photo upload)
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
||||
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
|
||||
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';
|
||||
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||
@@ -74,6 +74,7 @@ const tabs = [
|
||||
{ id: 'milestones', label: 'Milestones', icon: Milestone },
|
||||
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
||||
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
||||
{ id: 'inspections', label: 'Inspections', icon: ShieldCheck },
|
||||
{ id: 'activity', label: 'Activity', icon: Clock },
|
||||
{ id: 'docs', label: 'Docs', icon: FolderOpen },
|
||||
];
|
||||
@@ -192,6 +193,7 @@ const OwnerProjectDetail = () => {
|
||||
removeUserCommissionOverride, updateProject,
|
||||
addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
|
||||
advanceLifecycleStage,
|
||||
addInspection, addInspectionIssue, resolveInspectionIssue,
|
||||
} = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
const { can } = usePermissions();
|
||||
@@ -215,6 +217,11 @@ const OwnerProjectDetail = () => {
|
||||
const [commissionConfig, setCommissionConfig] = useState(null);
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
const [docs, setDocs] = useState(() => seedDocsMock(projectId));
|
||||
const [viewDoc, setViewDoc] = useState(null); // doc object being viewed
|
||||
@@ -1792,6 +1799,250 @@ const OwnerProjectDetail = () => {
|
||||
</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 */}
|
||||
{/* ── DOCS TAB ── */}
|
||||
{activeTab === 'docs' && (
|
||||
|
||||
Reference in New Issue
Block a user