1160 lines
88 KiB
React
1160 lines
88 KiB
React
import React, { useState, useMemo, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useMockStore } from '../data/mockStore';
|
|
import { useHailHistory, hailSeverityColor, daysAgo } from '../hooks/useHailRecon';
|
|
import { SpotlightCard } from '../components/SpotlightCard';
|
|
import { AnimatedCounter } from '../components/AnimatedCounter';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
|
|
} from 'recharts';
|
|
import {
|
|
ArrowLeft, Phone, Mail, MapPin, User, Briefcase, Shield, Clock,
|
|
FileText, ChevronRight, ArrowUpRight, ArrowDownLeft, ArrowRight,
|
|
MessageSquare, CheckCircle, ChevronDown, Activity, AlertTriangle,
|
|
TrendingUp, Calendar, DollarSign, GitPullRequest, AlertCircle,
|
|
Milestone, FolderOpen, Upload, Trash2, Eye, Pencil, X as XIcon,
|
|
File, StickyNote, Download, Image as ImageIcon, CloudLightning
|
|
} from 'lucide-react';
|
|
|
|
// ── Neomorphic constants (mirrors OwnerProjectDetail) ─────────────────────────
|
|
const NEO_PANEL_CLASS = "dark:bg-zinc-900/60 dark:backdrop-blur-3xl border border-zinc-200 dark:border-white/5 dark:shadow-[8px_8px_16px_rgba(0,0,0,0.6),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-3xl relative overflow-hidden transition-all duration-300";
|
|
const NEON_GREEN = "text-green-600 dark:text-[#39ff14] dark:drop-shadow-[0_0_8px_rgba(57,255,20,0.4)]";
|
|
const NEON_GOLD = "text-amber-600 dark:text-[#fda913] dark:drop-shadow-[0_0_8px_rgba(253,169,19,0.4)]";
|
|
const NEON_ORANGE = "text-orange-600 dark:text-[#ff4500] dark:drop-shadow-[0_0_8px_rgba(255,69,0,0.4)]";
|
|
const NEON_BLUE = "text-sky-600 dark:text-[#00f0ff] dark:drop-shadow-[0_0_8px_rgba(0,240,255,0.4)]";
|
|
|
|
function formatCurrency(n) {
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n || 0);
|
|
}
|
|
function formatDate(d) {
|
|
if (!d) return '—';
|
|
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
function getInitials(name) {
|
|
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
|
}
|
|
function getHealthColor(s) {
|
|
return s >= 70 ? 'text-emerald-500' : s >= 40 ? 'text-amber-500' : 'text-red-500';
|
|
}
|
|
function getVarianceColor(v) {
|
|
if (!v) return 'text-zinc-400';
|
|
return v < 0 ? NEON_GREEN : NEON_ORANGE;
|
|
}
|
|
function getMilestoneColor(status) {
|
|
if (status === 'completed') return 'bg-emerald-500';
|
|
if (status === 'in_progress') return 'bg-blue-500';
|
|
return 'bg-zinc-700';
|
|
}
|
|
|
|
// ── NeoCard ───────────────────────────────────────────────────────────────────
|
|
function NeoCard({ children, className = '', spotlightColor = 'rgba(255,255,255,0.05)' }) {
|
|
return (
|
|
<SpotlightCard className={`${NEO_PANEL_CLASS} h-full w-full ${className}`} spotlightColor={spotlightColor}>
|
|
<div className="relative z-10 w-full h-full p-5 lg:p-6 flex flex-col">
|
|
{children}
|
|
</div>
|
|
</SpotlightCard>
|
|
);
|
|
}
|
|
|
|
// ── Animated progress bar ─────────────────────────────────────────────────────
|
|
function NeoProgress({ pct, color = '#3B82F6', height = 'h-1.5' }) {
|
|
return (
|
|
<div className={`${height} w-full bg-zinc-200 dark:bg-black/80 rounded-full border border-zinc-200 dark:border-white/5 relative overflow-hidden`}>
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${Math.min(pct, 100)}%` }}
|
|
transition={{ duration: 1.5, type: 'spring', stiffness: 50, damping: 15 }}
|
|
className="absolute top-0 left-0 h-full rounded-full"
|
|
style={{ backgroundColor: color, boxShadow: `0 0 10px ${color}` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Stage mover dropdown ──────────────────────────────────────────────────────
|
|
function StageMoverDropdown({ lead, columns, onStageChange }) {
|
|
const [open, setOpen] = useState(false);
|
|
const stageColumns = columns.filter(c => c.type === 'stage').sort((a, b) => a.order - b.order);
|
|
const bucketColumns = columns.filter(c => c.type === 'bucket');
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setOpen(v => !v)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-200 text-sm font-semibold transition-colors"
|
|
>
|
|
Move Stage <ChevronDown size={13} />
|
|
</button>
|
|
{open && (
|
|
<div className="absolute right-0 top-full mt-2 z-50 bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden min-w-[200px]">
|
|
<p className="px-4 py-2 text-[10px] font-bold text-zinc-500 uppercase tracking-widest border-b border-white/5">Stages</p>
|
|
{stageColumns.map(col => (
|
|
<button key={col.id} onClick={() => { onStageChange(col.id, null); setOpen(false); }}
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-white/5 transition-colors ${col.id === lead.columnId && !lead.bucketId ? 'text-white font-semibold' : 'text-zinc-300'}`}>
|
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
|
{col.name}
|
|
{col.id === lead.columnId && !lead.bucketId && <CheckCircle size={12} className="ml-auto text-emerald-400" />}
|
|
</button>
|
|
))}
|
|
<p className="px-4 py-2 text-[10px] font-bold text-zinc-500 uppercase tracking-widest border-t border-white/5">Buckets</p>
|
|
{bucketColumns.map(col => (
|
|
<button key={col.id} onClick={() => { onStageChange(lead.columnId, col.id); setOpen(false); }}
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-white/5 transition-colors ${col.id === lead.bucketId ? 'text-white font-semibold' : 'text-zinc-300'}`}>
|
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
|
{col.name}
|
|
{col.id === lead.bucketId && <CheckCircle size={12} className="ml-auto text-emerald-400" />}
|
|
</button>
|
|
))}
|
|
{lead.bucketId && (
|
|
<>
|
|
<div className="border-t border-white/5" />
|
|
<button onClick={() => { onStageChange(lead.columnId, null); setOpen(false); }}
|
|
className="w-full px-4 py-2.5 text-sm text-left text-zinc-500 hover:bg-white/5 transition-colors">
|
|
Clear bucket
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Activity entry (stage movement) ──────────────────────────────────────────
|
|
function StageActivityItem({ entry, isLast }) {
|
|
const isFirst = !entry.from;
|
|
const isBucket = entry.to === 'Stuck' || entry.to === 'Follow-up';
|
|
const isRegress = entry.activity_type === 'regression';
|
|
const dotColor = isFirst ? '#3B82F6' : isBucket ? '#F59E0B' : isRegress ? '#F97316' : '#10B981';
|
|
const bgClass = isFirst ? 'bg-blue-500/15' : isBucket ? 'bg-amber-500/15' : isRegress ? 'bg-orange-500/15' : 'bg-emerald-500/15';
|
|
return (
|
|
<div className="flex gap-4">
|
|
<div className="flex flex-col items-center">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 border border-white/5 ${bgClass}`}>
|
|
{isFirst ? <ArrowRight size={13} style={{ color: dotColor }} /> :
|
|
isBucket ? <MessageSquare size={13} style={{ color: dotColor }} /> :
|
|
isRegress ? <ArrowDownLeft size={13} style={{ color: dotColor }} /> :
|
|
<ArrowUpRight size={13} style={{ color: dotColor }} />}
|
|
</div>
|
|
{!isLast && <div className="w-px flex-1 bg-zinc-200 dark:bg-white/5 my-2 min-h-[20px]" />}
|
|
</div>
|
|
<div className={`flex-1 min-w-0 ${!isLast ? 'pb-5' : ''}`}>
|
|
<div className="bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/[0.07] rounded-xl px-4 py-3 hover:bg-zinc-100 dark:hover:bg-white/[0.06] transition-colors">
|
|
<div className="mb-0.5">
|
|
{entry.from ? (
|
|
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
|
<span className="text-zinc-500">{entry.from}</span>
|
|
<ArrowRight size={12} className="inline mx-1.5 text-zinc-600" />
|
|
<span style={{ color: dotColor }}>{entry.to}</span>
|
|
</p>
|
|
) : (
|
|
<p className="text-sm font-semibold text-blue-400">Lead Created</p>
|
|
)}
|
|
<p className="text-xs text-zinc-600 mt-0.5">by <span className="font-medium text-zinc-400">{entry.by}</span> · {formatDate(entry.date)}</p>
|
|
</div>
|
|
{entry.note && <p className="text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed mt-2 pt-2 border-t border-zinc-200 dark:border-white/5">{entry.note}</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Docs helpers ──────────────────────────────────────────────────────────────
|
|
const DOC_TYPE_CONFIG = {
|
|
pdf: { color: '#ef4444', bg: 'rgba(239,68,68,0.15)', icon: FileText, label: 'PDF' },
|
|
docx: { color: '#3b82f6', bg: 'rgba(59,130,246,0.15)', icon: FileText, label: 'DOCX' },
|
|
doc: { color: '#3b82f6', bg: 'rgba(59,130,246,0.15)', icon: FileText, label: 'DOC' },
|
|
md: { color: '#a855f7', bg: 'rgba(168,85,247,0.15)', icon: File, label: 'MD' },
|
|
txt: { color: '#6b7280', bg: 'rgba(107,114,128,0.15)',icon: File, label: 'TXT' },
|
|
jpg: { color: '#10b981', bg: 'rgba(16,185,129,0.15)', icon: ImageIcon,label: 'JPG' },
|
|
jpeg: { color: '#10b981', bg: 'rgba(16,185,129,0.15)', icon: ImageIcon,label: 'JPG' },
|
|
png: { color: '#06b6d4', bg: 'rgba(6,182,212,0.15)', icon: ImageIcon,label: 'PNG' },
|
|
webp: { color: '#06b6d4', bg: 'rgba(6,182,212,0.15)', icon: ImageIcon,label: 'IMG' },
|
|
};
|
|
function getDocType(filename) {
|
|
const ext = (filename || '').split('.').pop().toLowerCase();
|
|
return DOC_TYPE_CONFIG[ext] || { color: '#9ca3af', bg: 'rgba(156,163,175,0.15)', icon: File, label: ext.toUpperCase() || 'FILE' };
|
|
}
|
|
function seedLeadDocs(leadId) {
|
|
const base = [
|
|
{ id: 'ld1', name: 'Insurance Claim Form.pdf', uploadedBy: 'Owner', uploadedDate: '2026-02-01', size: '245 KB', notes: 'Original claim. Reference for all supplements.' },
|
|
{ id: 'ld2', name: 'Signed Contract.pdf', uploadedBy: 'Frank Agent',uploadedDate: '2026-02-05', size: '512 KB', notes: 'Fully executed. See payment schedule p.4.' },
|
|
{ id: 'ld3', name: 'Inspection Report.pdf', uploadedBy: 'Frank Agent',uploadedDate: '2026-01-28', size: '1.1 MB', notes: 'Hail damage confirmed. Photos on p.7-12.' },
|
|
{ id: 'ld4', name: 'Before Photo - Front.jpg', uploadedBy: 'Frank Agent',uploadedDate: '2026-02-06', size: '2.8 MB', notes: '' },
|
|
{ id: 'ld5', name: 'Material Spec Sheet.md', uploadedBy: 'Owner', uploadedDate: '2026-02-07', size: '11 KB', notes: 'GAF Timberline HDZ — Class 4 IR.' },
|
|
{ id: 'ld6', name: 'Adjuster Notes.docx', uploadedBy: 'Owner', uploadedDate: '2026-02-10', size: '42 KB', notes: '' },
|
|
];
|
|
const n = 3 + (leadId.charCodeAt(leadId.length - 1) % 3);
|
|
return base.slice(0, n);
|
|
}
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
const TABS_FULL = [
|
|
{ id: 'overview', label: 'Overview', icon: Activity },
|
|
{ id: 'budget', label: 'Budget & Costs', icon: DollarSign },
|
|
{ id: 'milestones', label: 'Milestones', icon: Milestone },
|
|
{ id: 'changeOrders', label: 'Change Orders', icon: GitPullRequest },
|
|
{ id: 'rfis', label: 'RFIs', icon: FileText },
|
|
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
|
{ id: 'docs', label: 'Docs', icon: FolderOpen },
|
|
{ id: 'activity', label: 'Activity', icon: Clock },
|
|
];
|
|
const TABS_SIMPLE = [
|
|
{ id: 'overview', label: 'Overview', icon: Activity },
|
|
{ id: 'contact', label: 'Contact', icon: User },
|
|
{ id: 'docs', label: 'Docs', icon: FolderOpen},
|
|
{ id: 'activity', label: 'Activity', icon: Clock },
|
|
];
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
// ── Hail History Section ──────────────────────────────────────────────────────
|
|
function HailHistorySection({ leadId, lat, lng }) {
|
|
const { history, loading, hitCount, lastHit, maxSize } = useHailHistory(leadId, lat, lng, 12);
|
|
|
|
const summaryColor = maxSize >= 2.0 ? 'text-red-600 dark:text-red-400' :
|
|
maxSize >= 1.5 ? 'text-orange-600 dark:text-orange-400' :
|
|
maxSize >= 1.0 ? 'text-yellow-600 dark:text-yellow-400' :
|
|
'text-zinc-400';
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/40 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-zinc-100 dark:border-white/[0.04]">
|
|
<div className="p-2 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
|
<CloudLightning size={16} className="text-amber-500" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-900 dark:text-white">Hail Impact History</h3>
|
|
<p className="text-[10px] text-zinc-500 mt-0.5">Last 12 months · Powered by Hail Recon</p>
|
|
</div>
|
|
{!loading && hitCount > 0 && (
|
|
<div className="text-right">
|
|
<p className={`text-lg font-black font-mono ${summaryColor}`}>{hitCount}x</p>
|
|
<p className="text-[10px] text-zinc-400">
|
|
{lastHit ? `Last: ${daysAgo(lastHit.date)}d ago` : ''}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-5 py-4">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 py-4">
|
|
<div className="w-4 h-4 rounded-full border-2 border-amber-500 border-t-transparent animate-spin" />
|
|
<p className="text-xs text-zinc-400">Checking hail history...</p>
|
|
</div>
|
|
) : hitCount === 0 ? (
|
|
<div className="flex items-center gap-2 py-2">
|
|
<Shield size={14} className="text-emerald-500 shrink-0" />
|
|
<p className="text-sm text-zinc-500">No hail impacts recorded in the last 12 months.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{history.map((event, i) => {
|
|
const textCls = hailSeverityColor(event.hailSize, 'text');
|
|
const bgCls = hailSeverityColor(event.hailSize, 'bg');
|
|
const brdCls = hailSeverityColor(event.hailSize, 'border');
|
|
const days = daysAgo(event.date);
|
|
const dateObj = new Date(event.date);
|
|
const dateLabel = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
return (
|
|
<div key={i} className="flex items-center gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.04] last:border-0">
|
|
{/* Date */}
|
|
<div className="w-28 shrink-0">
|
|
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200">{dateLabel}</p>
|
|
<p className="text-[10px] text-zinc-400">{days === 0 ? 'Today' : `${days}d ago`}</p>
|
|
</div>
|
|
{/* Size badge */}
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-black border ${bgCls} ${brdCls} ${textCls}`}>
|
|
{event.hailSize?.toFixed(2)}"
|
|
</span>
|
|
{/* Severity label */}
|
|
<span className={`text-[11px] font-semibold ${textCls}`}>
|
|
{event.hailSize >= 2.0 ? 'Severe' :
|
|
event.hailSize >= 1.5 ? 'Significant' :
|
|
event.hailSize >= 1.0 ? 'Moderate' : 'Trace'}
|
|
</span>
|
|
{i === 0 && (
|
|
<span className="ml-auto text-[10px] font-bold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20">
|
|
Most Recent
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function LeadProjectPage() {
|
|
const { leadId } = useParams();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const { kanbanLeads, kanbanColumns, kanbanProjectData, moveKanbanLead } = useMockStore();
|
|
|
|
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
|
const lead = kanbanLeads.find(l => l.id === leadId);
|
|
const proj = kanbanProjectData?.[leadId] ?? null;
|
|
|
|
const tabs = proj ? TABS_FULL : TABS_SIMPLE;
|
|
const [activeTab, setActiveTab] = useState('overview');
|
|
|
|
// ── Docs state ────────────────────────────────────────────────────────────
|
|
const [docs, setDocs] = useState(() => seedLeadDocs(leadId));
|
|
const [viewDoc, setViewDoc] = useState(null);
|
|
const [editNoteDoc, setEditNoteDoc] = useState(null);
|
|
const [deleteDocId, setDeleteDocId] = useState(null);
|
|
const [uploadOpen, setUploadOpen] = useState(false);
|
|
const [uploadForm, setUploadForm] = useState({ name: '', notes: '' });
|
|
const fileInputRef = useRef(null);
|
|
|
|
const handleDocUpload = () => {
|
|
if (!uploadForm.name.trim()) return;
|
|
setDocs(prev => [{
|
|
id: `ld_${Date.now()}`, name: uploadForm.name.trim(),
|
|
uploadedBy: user?.name || 'Owner', uploadedDate: '2026-04-08', size: '—', notes: uploadForm.notes.trim(),
|
|
}, ...prev]);
|
|
setUploadOpen(false); setUploadForm({ name: '', notes: '' });
|
|
};
|
|
const handleFileSelect = (e) => { const f = e.target.files?.[0]; if (f) setUploadForm(p => ({ ...p, name: f.name })); };
|
|
const handleSaveNote = () => { setDocs(prev => prev.map(d => d.id === editNoteDoc.doc.id ? { ...d, notes: editNoteDoc.noteText } : d)); setEditNoteDoc(null); };
|
|
const handleDeleteDoc = () => { setDocs(prev => prev.filter(d => d.id !== deleteDocId)); setDeleteDocId(null); };
|
|
|
|
const handleStageChange = (newColumnId, newBucketId) => {
|
|
if (!lead) return;
|
|
if (newColumnId === lead.columnId && newBucketId === lead.bucketId) return;
|
|
moveKanbanLead(lead.id, newColumnId, newBucketId);
|
|
toast.success('Stage updated');
|
|
};
|
|
|
|
if (!lead) {
|
|
return (
|
|
<div className="min-h-screen bg-[#09090b] flex items-center justify-center">
|
|
<div className="text-center">
|
|
<FileText size={40} className="mx-auto mb-4 text-zinc-700" />
|
|
<h2 className="text-xl font-bold text-white">Lead not found</h2>
|
|
<button onClick={() => navigate(-1)} className="mt-4 px-4 py-2 bg-zinc-800 text-zinc-300 rounded-xl text-sm">Go back</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const stageColumns = [...kanbanColumns].filter(c => c.type === 'stage').sort((a, b) => a.order - b.order);
|
|
const currentStage = stageColumns.find(c => c.id === lead.columnId);
|
|
const bucketCol = lead.bucketId ? kanbanColumns.find(c => c.id === lead.bucketId) : null;
|
|
const stageIdx = stageColumns.findIndex(c => c.id === lead.columnId);
|
|
const stagePct = stageColumns.length > 0 ? Math.round(((stageIdx + 1) / stageColumns.length) * 100) : 0;
|
|
const completionPct = proj?.completionPct ?? stagePct;
|
|
|
|
// Budget chart
|
|
const budgetChartData = (proj?.budgetBreakdown || []).map(b => ({
|
|
name: b.category.length > 12 ? b.category.slice(0, 10) + '…' : b.category,
|
|
allocated: b.allocated / 1000, committed: b.committed / 1000, actual: b.actual / 1000,
|
|
}));
|
|
|
|
const CustomTooltip = ({ active, payload, label }) => {
|
|
if (active && payload?.length) return (
|
|
<div className="bg-zinc-900 border border-white/10 rounded-xl px-4 py-3 shadow-xl text-sm">
|
|
<p className="font-bold text-white mb-1">{label}</p>
|
|
{payload.map((p, i) => (
|
|
<p key={i} className="text-zinc-400">
|
|
<span className="font-semibold" style={{ color: p.color }}>{p.name}:</span> {formatCurrency(p.value * 1000)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
return null;
|
|
};
|
|
|
|
const invoiceTotalPaid = (proj?.invoices || []).filter(i => i.status === 'paid').reduce((s, i) => s + i.amount, 0);
|
|
const invoiceTotalPending = (proj?.invoices || []).filter(i => i.status !== 'paid').reduce((s, i) => s + i.amount, 0);
|
|
|
|
return (
|
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 relative">
|
|
{/* Ambient glow */}
|
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
|
</div>
|
|
|
|
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-6 pb-20">
|
|
|
|
{/* ── Header ── */}
|
|
<header>
|
|
<button onClick={() => navigate(`${basePath}/kanban`)}
|
|
className="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors mb-4">
|
|
<ArrowLeft size={16} /> Back to Pipeline
|
|
</button>
|
|
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b border-zinc-200 dark:border-white/5 pb-6">
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
|
{lead.name}
|
|
</h1>
|
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1 text-sm">{lead.address}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{currentStage && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wide"
|
|
style={{ backgroundColor: currentStage.color + '20', color: currentStage.color }}>
|
|
{currentStage.name}
|
|
</span>
|
|
)}
|
|
{bucketCol && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wide"
|
|
style={{ backgroundColor: bucketCol.color + '20', color: bucketCol.color }}>
|
|
{bucketCol.name}
|
|
</span>
|
|
)}
|
|
<StageMoverDropdown lead={lead} columns={kanbanColumns} onStageChange={handleStageChange} />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── Key Metrics Row ── */}
|
|
<section className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
{proj ? [
|
|
{ label: 'Estimated', value: formatCurrency(proj.estimatedAmount) },
|
|
{ label: 'Actual Cost', value: formatCurrency(proj.actualCost) },
|
|
{ label: 'Variance', value: `${(proj.variancePercent || 0) > 0 ? '+' : ''}${(proj.variancePercent || 0).toFixed(1)}%`, color: getVarianceColor(proj.variancePercent) },
|
|
{ label: 'Completion', value: `${proj.completionPct}%` },
|
|
{ label: 'Health', value: proj.healthScore, color: getHealthColor(proj.healthScore) },
|
|
{ label: 'Stage', value: currentStage?.name || '—' },
|
|
].map((m, i) => (
|
|
<SpotlightCard key={i} className="p-4">
|
|
<div className={`text-xl font-extrabold ${m.color || 'text-zinc-900 dark:text-white'}`}>{m.value}</div>
|
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">{m.label}</div>
|
|
</SpotlightCard>
|
|
)) : [
|
|
{ label: 'Stage', value: currentStage?.name || '—' },
|
|
{ label: 'Job Type', value: lead.jobType },
|
|
{ label: 'Category', value: lead.insuranceType },
|
|
{ label: 'Assigned To', value: lead.assignedAgentName || 'Unassigned' },
|
|
{ label: 'Created', value: formatDate(lead.createdDate) },
|
|
{ label: 'Stage Moves', value: lead.activity?.length ?? 0 },
|
|
].map((m, i) => (
|
|
<SpotlightCard key={i} className="p-4">
|
|
<div className="text-xl font-extrabold text-zinc-900 dark:text-white truncate">{m.value}</div>
|
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">{m.label}</div>
|
|
</SpotlightCard>
|
|
))}
|
|
</section>
|
|
|
|
{/* ── Tabs ── */}
|
|
<div className="flex overflow-x-auto gap-0.5 sm:gap-1 border-b border-zinc-200 dark:border-white/5 pb-px scrollbar-hide">
|
|
{tabs.map(tab => {
|
|
const TabIcon = tab.icon;
|
|
const isActive = activeTab === tab.id;
|
|
return (
|
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-3 text-xs sm:text-sm font-semibold whitespace-nowrap border-b-2 transition-colors ${isActive
|
|
? 'border-amber-500 text-amber-600 dark:text-amber-400'
|
|
: 'border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
|
>
|
|
<TabIcon size={16} /> <span className="hidden sm:inline">{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* ── Tab Content ── */}
|
|
<div>
|
|
|
|
{/* ── OVERVIEW ── */}
|
|
{activeTab === 'overview' && proj && (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Project Progression */}
|
|
<NeoCard spotlightColor="rgba(0,240,255,0.1)" className="lg:col-span-2">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Milestone className={NEON_BLUE} size={20} />
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Project Progression</h3>
|
|
</div>
|
|
<div className="relative pt-8 pb-4">
|
|
<div className="absolute top-[38px] left-[5%] right-[5%] h-1 bg-zinc-200 dark:bg-zinc-800 rounded-full z-0" />
|
|
<motion.div
|
|
initial={{ width: 0 }} animate={{ width: `${completionPct}%` }} transition={{ duration: 1.5 }}
|
|
className="absolute top-[38px] left-[5%] h-1 bg-gradient-to-r from-blue-600 to-[#00f0ff] rounded-full shadow-[0_0_10px_#00f0ff] z-0"
|
|
/>
|
|
<div className="relative z-10 flex justify-between px-[5%]">
|
|
{['Signed', 'Materials', 'On Site', 'Install', 'Complete'].map((step, i) => {
|
|
const isActive = (i * 25) <= completionPct;
|
|
return (
|
|
<div key={i} className="flex flex-col items-center gap-3 w-20">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 shadow-lg transition-colors ${isActive ? 'bg-zinc-50 dark:bg-zinc-900 border-sky-500 dark:border-[#00f0ff] text-sky-500 dark:text-[#00f0ff] dark:shadow-[0_0_15px_rgba(0,240,255,0.4)]' : 'bg-white dark:bg-black border-zinc-300 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600'}`}>
|
|
{isActive ? <CheckCircle size={14} /> : <span className="w-2 h-2 rounded-full bg-zinc-300 dark:bg-zinc-700" />}
|
|
</div>
|
|
<span className={`text-[10px] text-center font-bold tracking-wide uppercase leading-tight ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 dark:text-zinc-600'}`}>{step}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</NeoCard>
|
|
|
|
{/* Row: Contractor + Summary */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<NeoCard spotlightColor="rgba(253,169,19,0.08)">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<User className={NEON_GOLD} size={20} />
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Contact & Contractor</h3>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{[
|
|
{ icon: User, label: 'Client', value: lead.name },
|
|
{ icon: Phone, label: 'Phone', value: lead.phone },
|
|
{ icon: Mail, label: 'Email', value: lead.email },
|
|
{ icon: MapPin, label: 'Address', value: lead.address },
|
|
{ icon: Briefcase, label: 'Contractor', value: proj.contractorName },
|
|
{ icon: Phone, label: 'Crew Phone', value: proj.contractorPhone },
|
|
{ icon: Shield, label: 'Assigned Agent', value: lead.assignedAgentName },
|
|
].map(({ icon: Icon, label, value }) => value ? (
|
|
<div key={label} className="flex items-start gap-3 py-2 border-b border-zinc-100 dark:border-white/[0.04] last:border-0">
|
|
<Icon size={13} className="text-zinc-600 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-[10px] text-zinc-600">{label}</p>
|
|
<p className="text-sm text-zinc-700 dark:text-zinc-200 font-medium">{value}</p>
|
|
</div>
|
|
</div>
|
|
) : null)}
|
|
</div>
|
|
</NeoCard>
|
|
|
|
<NeoCard spotlightColor="rgba(57,255,20,0.06)">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<AlertCircle className={NEON_ORANGE} size={20} />
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Open Items</h3>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
|
{[
|
|
{ label: 'Change Orders', count: proj.changeOrders?.length || 0, color: '#fda913' },
|
|
{ label: 'Open RFIs', count: (proj.rfis || []).filter(r => r.status === 'open').length, color: '#ff4500' },
|
|
{ label: 'Pending Invoices', count: (proj.invoices || []).filter(i => i.status === 'pending').length, color: '#00f0ff' },
|
|
{ label: 'Milestones Done', count: (proj.milestones || []).filter(m => m.status === 'completed').length + '/' + (proj.milestones?.length || 0), color: '#39ff14' },
|
|
].map(item => (
|
|
<div key={item.label} className="bg-zinc-100 dark:bg-black/30 border border-zinc-200 dark:border-white/5 rounded-2xl p-4 text-center">
|
|
<p className="text-2xl font-black" style={{ color: item.color, fontFamily: 'Barlow Condensed, sans-serif' }}>{item.count}</p>
|
|
<p className="text-[10px] text-zinc-500 dark:text-zinc-600 mt-1">{item.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{lead.notes && (
|
|
<div className="p-4 bg-amber-500/5 border border-amber-500/10 rounded-2xl">
|
|
<p className="text-[10px] font-bold text-amber-500/70 uppercase tracking-widest mb-1.5">Field Notes</p>
|
|
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{lead.notes}</p>
|
|
</div>
|
|
)}
|
|
</NeoCard>
|
|
</div>
|
|
<HailHistorySection
|
|
leadId={leadId}
|
|
lat={lead.lat ?? 33.055}
|
|
lng={lead.lng ?? -96.752}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overview for simple (no project data) */}
|
|
{activeTab === 'overview' && !proj && (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<User className={NEON_BLUE} size={20} />
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Contact</h3>
|
|
</div>
|
|
{[
|
|
{ icon: User, label: 'Name', value: lead.name },
|
|
{ icon: Phone, label: 'Phone', value: lead.phone },
|
|
{ icon: Mail, label: 'Email', value: lead.email },
|
|
{ icon: MapPin, label: 'Address', value: lead.address },
|
|
{ icon: Briefcase, label: 'Job Type', value: lead.jobType },
|
|
{ icon: Shield, label: 'Category', value: lead.insuranceType },
|
|
{ icon: User, label: 'Assigned Agent', value: lead.assignedAgentName },
|
|
].map(({ icon: Icon, label, value }) => value ? (
|
|
<div key={label} className="flex items-start gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.04] last:border-0">
|
|
<Icon size={13} className="text-zinc-600 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-[10px] text-zinc-600">{label}</p>
|
|
<p className="text-sm text-zinc-700 dark:text-zinc-200 font-medium">{value}</p>
|
|
</div>
|
|
</div>
|
|
) : null)}
|
|
</NeoCard>
|
|
|
|
<NeoCard spotlightColor="rgba(245,158,11,0.06)">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<Activity className={NEON_GOLD} size={20} />
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Pipeline Position</h3>
|
|
</div>
|
|
<NeoProgress pct={stagePct} color={currentStage?.color ?? '#3B82F6'} height="h-2" />
|
|
<div className="flex items-center gap-0.5 flex-wrap mt-4 mb-5">
|
|
{stageColumns.map((col, i) => (
|
|
<React.Fragment key={col.id}>
|
|
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded transition-colors"
|
|
style={i === stageIdx ? { color: col.color } : { color: i < stageIdx ? '#71717a' : '#a1a1aa' }}>
|
|
{col.name}
|
|
</span>
|
|
{i < stageColumns.length - 1 && <ChevronRight size={10} className="text-zinc-400 dark:text-zinc-800 shrink-0" />}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
{lead.notes && (
|
|
<div className="p-4 bg-amber-500/5 border border-amber-500/10 rounded-2xl mt-auto">
|
|
<p className="text-[10px] font-bold text-amber-500/70 uppercase tracking-widest mb-1.5">Notes</p>
|
|
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{lead.notes}</p>
|
|
</div>
|
|
)}
|
|
<div className="mt-4 p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl">
|
|
<p className="text-xs text-zinc-500">
|
|
Full project details (budget, milestones, invoices) become available once the lead is <span className="text-blue-400 font-semibold">Signed</span>.
|
|
</p>
|
|
</div>
|
|
</NeoCard>
|
|
</div>
|
|
<HailHistorySection
|
|
leadId={leadId}
|
|
lat={lead.lat ?? 33.055}
|
|
lng={lead.lng ?? -96.752}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── CONTACT (simple only) ── */}
|
|
{activeTab === 'contact' && !proj && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
|
|
<p className="text-[10px] font-bold text-zinc-600 uppercase tracking-widest mb-4">Contact Information</p>
|
|
{[
|
|
{ icon: User, label: 'Full Name', value: lead.name },
|
|
{ icon: Phone, label: 'Phone', value: lead.phone },
|
|
{ icon: Mail, label: 'Email', value: lead.email },
|
|
{ icon: MapPin, label: 'Address', value: lead.address },
|
|
].map(({ icon: Icon, label, value }) => value ? (
|
|
<div key={label} className="flex items-start gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.04] last:border-0">
|
|
<Icon size={13} className="text-zinc-600 shrink-0 mt-0.5" />
|
|
<div><p className="text-[10px] text-zinc-600">{label}</p><p className="text-sm text-zinc-700 dark:text-zinc-200 font-medium">{value}</p></div>
|
|
</div>
|
|
) : null)}
|
|
</NeoCard>
|
|
<NeoCard spotlightColor="rgba(139,92,246,0.08)">
|
|
<p className="text-[10px] font-bold text-zinc-600 uppercase tracking-widest mb-4">Quick Actions</p>
|
|
{lead.phone && (
|
|
<a href={`tel:${lead.phone}`} className="flex items-center gap-3 px-4 py-3 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 border border-zinc-100 dark:border-white/5 transition-colors mb-2">
|
|
<Phone size={14} className="text-emerald-400" /><span className="text-sm text-zinc-600 dark:text-zinc-300">Call {lead.phone}</span>
|
|
</a>
|
|
)}
|
|
{lead.email && (
|
|
<a href={`mailto:${lead.email}`} className="flex items-center gap-3 px-4 py-3 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 border border-zinc-100 dark:border-white/5 transition-colors mb-2">
|
|
<Mail size={14} className="text-blue-400" /><span className="text-sm text-zinc-600 dark:text-zinc-300">Email {lead.email}</span>
|
|
</a>
|
|
)}
|
|
{lead.address && (
|
|
<a href={`https://maps.google.com/?q=${encodeURIComponent(lead.address)}`} target="_blank" rel="noopener noreferrer"
|
|
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 border border-zinc-100 dark:border-white/5 transition-colors">
|
|
<MapPin size={14} className="text-amber-400" /><span className="text-sm text-zinc-600 dark:text-zinc-300">View on Maps</span>
|
|
</a>
|
|
)}
|
|
</NeoCard>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── BUDGET & COSTS ── */}
|
|
{activeTab === 'budget' && proj && (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ label: 'Estimated', value: formatCurrency(proj.estimatedAmount), color: NEON_BLUE },
|
|
{ label: 'Committed', value: formatCurrency(proj.budgetBreakdown?.reduce((s, b) => s + b.committed, 0)), color: NEON_GOLD },
|
|
{ label: 'Actual Spent', value: formatCurrency(proj.actualCost), color: proj.actualCost > proj.estimatedAmount ? NEON_ORANGE : NEON_GREEN },
|
|
{ label: 'Variance', value: `${(proj.variancePercent || 0) > 0 ? '+' : ''}${(proj.variancePercent || 0).toFixed(1)}%`, color: getVarianceColor(proj.variancePercent) },
|
|
].map((m, i) => (
|
|
<SpotlightCard key={i} className="p-5">
|
|
<div className={`text-2xl font-extrabold ${m.color}`} style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>{m.value}</div>
|
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">{m.label}</div>
|
|
</SpotlightCard>
|
|
))}
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
{budgetChartData.length > 0 && (
|
|
<NeoCard spotlightColor="rgba(0,240,255,0.08)">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<DollarSign className={NEON_BLUE} size={20} />
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Budget Breakdown</h3>
|
|
</div>
|
|
<div className="h-56">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={budgetChartData} barSize={14} barGap={4}>
|
|
<XAxis dataKey="name" tick={{ fill: '#52525b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
<YAxis tick={{ fill: '#52525b', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={v => `$${v}k`} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Bar dataKey="allocated" name="Allocated" fill="#3b82f6" radius={[4,4,0,0]} />
|
|
<Bar dataKey="committed" name="Committed" fill="#fda913" radius={[4,4,0,0]} />
|
|
<Bar dataKey="actual" name="Actual" fill="#39ff14" radius={[4,4,0,0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="flex items-center gap-6 mt-4 justify-center">
|
|
{[['#3b82f6','Allocated'],['#fda913','Committed'],['#39ff14','Actual']].map(([c, l]) => (
|
|
<div key={l} className="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: c }} /> {l}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</NeoCard>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<NeoCard spotlightColor="rgba(253,169,19,0.06)">
|
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase mb-5">Line Item Breakdown</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-zinc-200 dark:border-white/5 text-[10px] uppercase tracking-wider text-zinc-500 dark:text-zinc-600">
|
|
<th className="text-left pb-3 font-bold">Category</th>
|
|
<th className="text-right pb-3 font-bold">Allocated</th>
|
|
<th className="text-right pb-3 font-bold">Committed</th>
|
|
<th className="text-right pb-3 font-bold">Actual</th>
|
|
<th className="text-right pb-3 font-bold">Remaining</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
|
|
{(proj.budgetBreakdown || []).map((b, i) => {
|
|
const rem = b.allocated - b.actual;
|
|
return (
|
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/[0.03] transition-colors">
|
|
<td className="py-3 text-zinc-600 dark:text-zinc-300 font-medium">{b.category}</td>
|
|
<td className="py-3 text-right font-mono text-zinc-400">{formatCurrency(b.allocated)}</td>
|
|
<td className="py-3 text-right font-mono text-zinc-400">{formatCurrency(b.committed)}</td>
|
|
<td className="py-3 text-right font-mono text-zinc-300">{formatCurrency(b.actual)}</td>
|
|
<td className={`py-3 text-right font-mono font-semibold ${rem >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>{formatCurrency(rem)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t border-zinc-200 dark:border-white/10">
|
|
<td className="py-3 font-bold text-zinc-900 dark:text-white text-[11px] uppercase tracking-wider">Total</td>
|
|
<td className="py-3 text-right font-mono font-bold text-zinc-900 dark:text-white">{formatCurrency(proj.estimatedAmount)}</td>
|
|
<td className="py-3 text-right font-mono font-bold text-zinc-900 dark:text-white">{formatCurrency(proj.budgetBreakdown?.reduce((s, b) => s + b.committed, 0))}</td>
|
|
<td className="py-3 text-right font-mono font-bold text-zinc-900 dark:text-white">{formatCurrency(proj.actualCost)}</td>
|
|
<td className={`py-3 text-right font-mono font-bold ${(proj.estimatedAmount - proj.actualCost) >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{formatCurrency(proj.estimatedAmount - proj.actualCost)}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</NeoCard>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── MILESTONES ── */}
|
|
{activeTab === 'milestones' && proj && (
|
|
<NeoCard spotlightColor="rgba(57,255,20,0.08)">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Milestone className={NEON_GREEN} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Project Milestones</h3>
|
|
<span className="text-xs text-zinc-600 ml-auto">
|
|
{(proj.milestones || []).filter(m => m.status === 'completed').length} / {proj.milestones?.length || 0} complete
|
|
</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{(proj.milestones || []).map((m, i) => {
|
|
const dotColor = m.status === 'completed' ? '#39ff14' : m.status === 'in_progress' ? '#00f0ff' : '#3f3f46';
|
|
return (
|
|
<div key={m.id} className="flex items-start gap-4 p-4 rounded-2xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/[0.06] hover:bg-zinc-100 dark:hover:bg-white/[0.06] transition-colors">
|
|
<div className={`w-4 h-4 rounded-full mt-0.5 shrink-0`} style={{ backgroundColor: dotColor }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="font-semibold text-zinc-700 dark:text-zinc-200 text-sm">{m.name}</p>
|
|
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-full ${
|
|
m.status === 'completed' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' :
|
|
m.status === 'in_progress' ? 'bg-blue-500/15 text-blue-600 dark:text-blue-400' :
|
|
'bg-zinc-200 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-600'
|
|
}`}>{m.status.replace('_', ' ')}</span>
|
|
</div>
|
|
{m.date && <p className="text-xs text-zinc-600 mt-0.5">{formatDate(m.date)}</p>}
|
|
{m.notes && <p className="text-xs text-zinc-500 mt-1">{m.notes}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</NeoCard>
|
|
)}
|
|
|
|
{/* ── CHANGE ORDERS ── */}
|
|
{activeTab === 'changeOrders' && proj && (
|
|
<NeoCard spotlightColor="rgba(253,169,19,0.08)">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<GitPullRequest className={NEON_GOLD} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Change Orders</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 ml-auto">
|
|
{proj.changeOrders?.length || 0}
|
|
</span>
|
|
</div>
|
|
{(proj.changeOrders || []).length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<GitPullRequest size={40} className="mx-auto mb-4 text-zinc-700" />
|
|
<p className="text-zinc-500 font-semibold">No change orders</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{proj.changeOrders.map(co => (
|
|
<div key={co.id} className="p-4 rounded-2xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/[0.06] hover:bg-zinc-100 dark:hover:bg-white/[0.06] transition-colors">
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<p className="font-semibold text-zinc-700 dark:text-zinc-200 text-sm">{co.title}</p>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${co.status === 'approved' ? 'bg-emerald-500/15 text-emerald-400' : co.status === 'rejected' ? 'bg-red-500/15 text-red-400' : 'bg-amber-500/15 text-amber-400'}`}>
|
|
{co.status}
|
|
</span>
|
|
<span className="text-sm font-bold text-amber-400">+{formatCurrency(co.amount)}</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-zinc-500">{co.description}</p>
|
|
<p className="text-xs text-zinc-600 mt-2">Requested by {co.requestedBy} · {formatDate(co.date)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</NeoCard>
|
|
)}
|
|
|
|
{/* ── RFIs ── */}
|
|
{activeTab === 'rfis' && proj && (
|
|
<NeoCard spotlightColor="rgba(255,69,0,0.08)">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<FileText className={NEON_ORANGE} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Requests for Information</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 ml-auto">
|
|
{proj.rfis?.length || 0}
|
|
</span>
|
|
</div>
|
|
{(proj.rfis || []).length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<FileText size={40} className="mx-auto mb-4 text-zinc-700" />
|
|
<p className="text-zinc-500 font-semibold">No RFIs</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-zinc-200 dark:border-white/5 text-[10px] uppercase tracking-wider text-zinc-500 dark:text-zinc-600">
|
|
<tr>
|
|
<th className="text-left pb-3 font-bold">ID</th>
|
|
<th className="text-left pb-3 font-bold">Subject</th>
|
|
<th className="pb-3 font-bold">Status</th>
|
|
<th className="text-left pb-3 font-bold">Submitted By</th>
|
|
<th className="text-left pb-3 font-bold">Opened</th>
|
|
<th className="text-left pb-3 font-bold">Closed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
|
|
{proj.rfis.map((r, i) => (
|
|
<tr key={r.id} className="hover:bg-zinc-50 dark:hover:bg-white/[0.03] transition-colors">
|
|
<td className="py-3 font-mono text-zinc-500 text-xs">{r.id.toUpperCase()}</td>
|
|
<td className="py-3 text-zinc-700 dark:text-zinc-200 font-semibold">{r.subject}</td>
|
|
<td className="py-3 text-center">
|
|
<span className={`text-[10px] font-bold px-2 py-1 rounded-full uppercase ${r.status === 'closed' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-amber-500/15 text-amber-400'}`}>
|
|
{r.status}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 text-zinc-400">{r.submittedBy}</td>
|
|
<td className="py-3 font-mono text-zinc-500 text-xs">{r.openedDate}</td>
|
|
<td className="py-3 font-mono text-zinc-500 text-xs">{r.closedDate || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</NeoCard>
|
|
)}
|
|
|
|
{/* ── INVOICES ── */}
|
|
{activeTab === 'invoices' && proj && (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{[
|
|
{ label: 'Total Contract', value: formatCurrency(proj.estimatedAmount), color: NEON_BLUE },
|
|
{ label: 'Collected', value: formatCurrency(invoiceTotalPaid), color: NEON_GREEN },
|
|
{ label: 'Outstanding', value: formatCurrency(invoiceTotalPending), color: invoiceTotalPending > 0 ? NEON_GOLD : 'text-zinc-400' },
|
|
].map((m, i) => (
|
|
<SpotlightCard key={i} className="p-5">
|
|
<div className={`text-2xl font-extrabold ${m.color}`} style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>{m.value}</div>
|
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">{m.label}</div>
|
|
</SpotlightCard>
|
|
))}
|
|
</div>
|
|
<NeoCard spotlightColor="rgba(0,240,255,0.06)">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<DollarSign className={NEON_BLUE} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Invoice Schedule</h3>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-zinc-200 dark:border-white/5 text-[10px] uppercase tracking-wider text-zinc-500 dark:text-zinc-600">
|
|
<tr>
|
|
<th className="text-left pb-3 font-bold">Description</th>
|
|
<th className="text-right pb-3 font-bold">Amount</th>
|
|
<th className="pb-3 font-bold text-center">Status</th>
|
|
<th className="text-left pb-3 font-bold">Due</th>
|
|
<th className="text-left pb-3 font-bold">Paid</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
|
|
{(proj.invoices || []).map(inv => (
|
|
<tr key={inv.id} className="hover:bg-zinc-50 dark:hover:bg-white/[0.03] transition-colors">
|
|
<td className="py-3 text-zinc-600 dark:text-zinc-300 font-medium">{inv.description}</td>
|
|
<td className="py-3 text-right font-mono text-zinc-700 dark:text-zinc-200 font-semibold">{formatCurrency(inv.amount)}</td>
|
|
<td className="py-3 text-center">
|
|
<span className={`text-[10px] font-bold px-2 py-1 rounded-full uppercase ${
|
|
inv.status === 'paid' ? 'bg-emerald-500/15 text-emerald-400' :
|
|
inv.status === 'overdue' ? 'bg-red-500/15 text-red-400' :
|
|
'bg-amber-500/15 text-amber-400'
|
|
}`}>{inv.status}</span>
|
|
</td>
|
|
<td className="py-3 font-mono text-zinc-500 text-xs">{inv.dueDate || '—'}</td>
|
|
<td className="py-3 font-mono text-zinc-500 text-xs">{inv.paidDate || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</NeoCard>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── DOCS (shared across simple + full) ── */}
|
|
{activeTab === 'docs' && (
|
|
<NeoCard spotlightColor="rgba(59,130,246,0.08)">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<FolderOpen className={NEON_BLUE} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Project Documents</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">{docs.length}</span>
|
|
</div>
|
|
<button onClick={() => setUploadOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors">
|
|
<Upload size={14} /> Upload
|
|
</button>
|
|
</div>
|
|
{docs.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<FolderOpen size={48} className="mx-auto mb-4 text-zinc-700" />
|
|
<p className="font-bold text-lg text-zinc-900 dark:text-white">No documents yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{docs.map(doc => {
|
|
const cfg = getDocType(doc.name);
|
|
const DocIcon = cfg.icon;
|
|
return (
|
|
<div key={doc.id} className="flex items-start gap-4 px-4 py-4 rounded-2xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/[0.06] hover:bg-zinc-100 dark:hover:bg-white/[0.07] transition-colors group">
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: cfg.bg }}>
|
|
<DocIcon size={18} style={{ color: cfg.color }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<p className="font-semibold text-zinc-900 dark:text-white text-sm truncate">{doc.name}</p>
|
|
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded-md uppercase" style={{ backgroundColor: cfg.bg, color: cfg.color }}>{cfg.label}</span>
|
|
</div>
|
|
<p className="text-xs text-zinc-600 mt-0.5">{doc.size} · {doc.uploadedBy} · {doc.uploadedDate}</p>
|
|
{doc.notes ? (
|
|
<p className="text-xs text-zinc-500 mt-1.5 flex items-start gap-1">
|
|
<StickyNote size={11} className="text-amber-500/60 shrink-0 mt-px" />
|
|
<span className="line-clamp-2">{doc.notes}</span>
|
|
</p>
|
|
) : (
|
|
<button onClick={() => setEditNoteDoc({ doc, noteText: '' })} className="text-xs text-zinc-700 hover:text-zinc-400 mt-1 transition-colors">+ Add note</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button onClick={() => setViewDoc(doc)} className="p-1.5 rounded-lg bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-500 dark:text-zinc-400 hover:text-sky-500 dark:hover:text-[#00f0ff] transition-colors" title="View"><Eye size={14} /></button>
|
|
<button onClick={() => setEditNoteDoc({ doc, noteText: doc.notes || '' })} className="p-1.5 rounded-lg bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-500 dark:text-zinc-400 hover:text-amber-500 dark:hover:text-amber-400 transition-colors" title="Edit note"><Pencil size={14} /></button>
|
|
<button onClick={() => setDeleteDocId(doc.id)} className="p-1.5 rounded-lg bg-zinc-100 dark:bg-white/5 hover:bg-red-50 dark:hover:bg-red-500/20 text-zinc-500 dark:text-zinc-400 hover:text-red-500 dark:hover:text-red-400 transition-colors" title="Delete"><Trash2 size={14} /></button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</NeoCard>
|
|
)}
|
|
|
|
{/* ── ACTIVITY ── */}
|
|
{activeTab === 'activity' && (
|
|
<div className="flex flex-col gap-6">
|
|
{/* Work timeline (project-level) */}
|
|
{proj && (proj.workTimeline || []).length > 0 && (
|
|
<NeoCard spotlightColor="rgba(0,240,255,0.06)">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Activity className={NEON_BLUE} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Work Timeline</h3>
|
|
</div>
|
|
<div className="relative">
|
|
<div className="absolute left-6 top-0 bottom-0 w-px bg-zinc-200 dark:bg-white/10" />
|
|
<div className="space-y-5">
|
|
{proj.workTimeline.map((a, i) => (
|
|
<div key={i} className="relative flex items-start gap-4 pl-12">
|
|
<div className="absolute left-[21px] top-1.5 w-2.5 h-2.5 rounded-full bg-white dark:bg-[#09090b] border-2 border-sky-400 dark:border-[#00f0ff]" />
|
|
<div className="flex-1 p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<div className="font-bold text-zinc-900 dark:text-white text-sm">{a.action}</div>
|
|
{a.details && <div className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">{a.details}</div>}
|
|
</div>
|
|
<div className="text-right shrink-0 ml-4">
|
|
<div className="text-xs font-mono text-zinc-500">{a.date}</div>
|
|
{a.user && <div className="text-[10px] uppercase tracking-wider text-sky-500 dark:text-[#00f0ff] mt-1">{a.user}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</NeoCard>
|
|
)}
|
|
|
|
{/* Stage movement log */}
|
|
<NeoCard spotlightColor="rgba(57,255,20,0.06)">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<TrendingUp className={NEON_GREEN} size={20} />
|
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Stage Movement Log</h3>
|
|
</div>
|
|
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-600 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/5 px-2 py-0.5 rounded-full">
|
|
{lead.activity?.length ?? 0} entries
|
|
</span>
|
|
</div>
|
|
{(lead.activity || []).length > 0 ? (
|
|
(lead.activity || []).map((entry, i) => (
|
|
<StageActivityItem key={i} entry={entry} isLast={i === (lead.activity.length - 1)} />
|
|
))
|
|
) : (
|
|
<p className="text-sm text-zinc-700 text-center py-8">No stage movements recorded.</p>
|
|
)}
|
|
</NeoCard>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Upload Modal ── */}
|
|
{uploadOpen && (
|
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
|
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
|
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-md p-6" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h3 className="font-bold text-white text-base">Upload Document</h3>
|
|
<p className="text-xs text-zinc-500 mt-0.5">Supports PDF, DOCX, MD, TXT, JPG, PNG, WEBP</p>
|
|
</div>
|
|
<button onClick={() => setUploadOpen(false)} className="p-1.5 rounded-lg text-zinc-500 hover:bg-white/10 transition-colors"><XIcon size={16} /></button>
|
|
</div>
|
|
<div onClick={() => fileInputRef.current?.click()}
|
|
className="border-2 border-dashed border-white/10 hover:border-blue-500/50 rounded-2xl p-8 text-center cursor-pointer transition-colors mb-4 bg-white/[0.02] hover:bg-blue-500/5">
|
|
<Upload size={28} className="mx-auto mb-3 text-zinc-600" />
|
|
<p className="text-sm font-semibold text-zinc-400">Click to select a file</p>
|
|
{uploadForm.name && <p className="text-xs font-bold text-blue-400 mt-2">{uploadForm.name}</p>}
|
|
</div>
|
|
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileSelect} accept=".pdf,.docx,.doc,.md,.txt,.jpg,.jpeg,.png,.webp" />
|
|
{!uploadForm.name && (
|
|
<input type="text" placeholder="Or type filename (e.g. Contract.pdf)" value={uploadForm.name}
|
|
onChange={e => setUploadForm(p => ({ ...p, name: e.target.value }))}
|
|
className="w-full px-4 py-2.5 rounded-xl bg-black/40 border border-white/10 text-zinc-200 text-sm placeholder-zinc-600 outline-none focus:ring-2 focus:ring-blue-500/30 mb-4" />
|
|
)}
|
|
<textarea value={uploadForm.notes} onChange={e => setUploadForm(p => ({ ...p, notes: e.target.value }))}
|
|
placeholder="Notes about this document (optional)..." rows={3}
|
|
className="w-full px-4 py-2.5 rounded-xl bg-black/40 border border-white/10 text-zinc-200 text-sm placeholder-zinc-600 outline-none focus:ring-2 focus:ring-blue-500/30 resize-none mb-4" />
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setUploadOpen(false)} className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">Cancel</button>
|
|
<button onClick={handleDocUpload} disabled={!uploadForm.name.trim()} className="flex-1 px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm font-semibold transition-colors">Upload</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── View Doc Modal ── */}
|
|
{viewDoc && (() => {
|
|
const cfg = getDocType(viewDoc.name); const DocIcon = cfg.icon;
|
|
return (
|
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={() => setViewDoc(null)}>
|
|
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
|
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-md p-6" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-start justify-between mb-5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: cfg.bg }}><DocIcon size={18} style={{ color: cfg.color }} /></div>
|
|
<div><h3 className="font-bold text-white text-sm">{viewDoc.name}</h3><p className="text-xs text-zinc-500">{viewDoc.size} · {viewDoc.uploadedDate}</p></div>
|
|
</div>
|
|
<button onClick={() => setViewDoc(null)} className="p-1.5 rounded-lg text-zinc-500 hover:bg-white/10 transition-colors"><XIcon size={16} /></button>
|
|
</div>
|
|
<div className="bg-black/40 rounded-2xl border border-white/5 p-8 flex flex-col items-center justify-center mb-4 min-h-[140px]">
|
|
<DocIcon size={44} style={{ color: cfg.color }} className="mb-3" />
|
|
<p className="text-sm text-zinc-400 font-semibold">{viewDoc.name}</p>
|
|
<p className="text-xs text-zinc-600 mt-1">Uploaded by {viewDoc.uploadedBy}</p>
|
|
</div>
|
|
{viewDoc.notes && (
|
|
<div className="bg-amber-500/5 border border-amber-500/10 rounded-xl px-4 py-3 mb-4">
|
|
<div className="flex items-center gap-1.5 mb-1"><StickyNote size={11} className="text-amber-500/60" /><p className="text-[10px] font-bold text-amber-500/70 uppercase tracking-widest">Note</p></div>
|
|
<p className="text-sm text-zinc-300">{viewDoc.notes}</p>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setViewDoc(null)} className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">Close</button>
|
|
<button onClick={() => alert('Download simulated.')} className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors"><Download size={14} /> Download</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* ── Edit Note Modal ── */}
|
|
{editNoteDoc && (
|
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={() => setEditNoteDoc(null)}>
|
|
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
|
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-md p-6" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div><h3 className="font-bold text-white text-base">Edit Note</h3><p className="text-xs text-zinc-500 mt-0.5 truncate max-w-xs">{editNoteDoc.doc.name}</p></div>
|
|
<button onClick={() => setEditNoteDoc(null)} className="p-1.5 rounded-lg text-zinc-500 hover:bg-white/10 transition-colors"><XIcon size={16} /></button>
|
|
</div>
|
|
<textarea value={editNoteDoc.noteText} onChange={e => setEditNoteDoc(p => ({ ...p, noteText: e.target.value }))}
|
|
placeholder="Add a note about this document..." rows={4} autoFocus
|
|
className="w-full px-4 py-2.5 rounded-xl bg-black/40 border border-white/10 text-zinc-200 text-sm placeholder-zinc-600 outline-none focus:ring-2 focus:ring-amber-500/30 resize-none mb-4" />
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setEditNoteDoc(null)} className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">Cancel</button>
|
|
<button onClick={handleSaveNote} className="flex-1 px-4 py-2.5 rounded-xl bg-amber-500 hover:bg-amber-600 text-black text-sm font-bold transition-colors">Save Note</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Delete Confirm ── */}
|
|
{deleteDocId && (
|
|
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={() => setDeleteDocId(null)}>
|
|
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
|
|
className="bg-zinc-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-sm p-6" onClick={e => e.stopPropagation()}>
|
|
<div className="w-12 h-12 rounded-2xl bg-red-500/15 flex items-center justify-center mx-auto mb-4"><Trash2 size={22} className="text-red-400" /></div>
|
|
<h3 className="font-bold text-white text-base text-center">Delete Document?</h3>
|
|
<p className="text-sm text-zinc-500 text-center mt-1 mb-5">{docs.find(d => d.id === deleteDocId)?.name} will be permanently removed.</p>
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setDeleteDocId(null)} className="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-zinc-400 text-sm font-semibold hover:bg-white/5 transition-colors">Cancel</button>
|
|
<button onClick={handleDeleteDoc} className="flex-1 px-4 py-2.5 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-bold transition-colors">Delete</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|