fix(kanban): responsive layout, drag-drop offset, and project view enhancements
Kanban pipeline board: - Fix h-full flex-col layout so board fills viewport height on all screen sizes - KanbanColumn drop zone: flex-1 min-h-0 so it expands to fill full column height - KanbanCard: extract KanbanCardDisplay as named export for DragOverlay rendering above overflow containers - KanbanPage: lift DndContext to page root; add "Add Stage" shortcut to page header - PageTransition: remove slide-in-from-bottom-4 transform — the CSS transform ancestor was creating a containing block for position:fixed DragOverlay, causing drag ghost to render at a viewport offset - Migrate fireCampaignToast to toast.custom() Sonner v2 API with explicit card styling Project & lead views: - LeadProjectPage: enrich project detail with richer info sections aligned with project stage - OwnerProjectDetail: expand construction project detail with additional data panels - OwnerProjectList: project list view improvements - mockStore: extend lead/project data to support richer project detail rendering
This commit is contained in:
+85
-72
@@ -1,16 +1,16 @@
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
||||
useSensor, useSensors, closestCorners
|
||||
useSensor, useSensors, pointerWithin, rectIntersection
|
||||
} from '@dnd-kit/core';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useAuth, ROLES } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { toast } from 'sonner';
|
||||
import { LayoutGrid, Search, Filter, X, Send, Pencil } from 'lucide-react';
|
||||
import { LayoutGrid, Search, Filter, X, Send, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import KanbanCard from '../components/kanban/KanbanCard';
|
||||
import KanbanCard, { KanbanCardDisplay } from '../components/kanban/KanbanCard';
|
||||
import KanbanColumn from '../components/kanban/KanbanColumn';
|
||||
import LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
||||
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
||||
@@ -20,45 +20,43 @@ function fireCampaignToast(lead, column, onEdit) {
|
||||
if (!column?.campaignMessage) return;
|
||||
const msg = column.campaignMessage.replace('{name}', lead.name.split(' ')[0]);
|
||||
|
||||
toast(
|
||||
({ id }) => {
|
||||
const [secs, setSecs] = React.useState(20);
|
||||
React.useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); toast.dismiss(id); return 0; }
|
||||
return s - 1;
|
||||
}), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Send size={13} className="text-blue-500 shrink-0" />
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-100">Campaign Message</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-zinc-400">Sending in {secs}s</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { toast.dismiss(id); onEdit(msg, lead, column); }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-[11px] font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Pencil size={10} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { toast.dismiss(id); toast.success('Campaign message sent'); }}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 text-[11px] font-semibold hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Send Now
|
||||
</button>
|
||||
// Sonner v2: toast.custom(fn) where fn receives the toast ID directly (string)
|
||||
toast.custom((toastId) => {
|
||||
const [secs, setSecs] = React.useState(20);
|
||||
React.useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); toast.dismiss(toastId); return 0; }
|
||||
return s - 1;
|
||||
}), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-xl p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Send size={13} className="text-blue-500 shrink-0" />
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-100">Campaign Message</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-zinc-400">Sending in {secs}s</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ duration: 20000, position: 'bottom-right' }
|
||||
);
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { toast.dismiss(toastId); onEdit(msg, lead, column); }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-[11px] font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Pencil size={10} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { toast.dismiss(toastId); toast.success('Campaign message sent'); }}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 text-[11px] font-semibold hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Send Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, { duration: 20000, position: 'bottom-right' });
|
||||
}
|
||||
|
||||
// Campaign Edit Modal
|
||||
@@ -110,7 +108,6 @@ export default function KanbanPage() {
|
||||
const canManage = user?.role === ROLES.OWNER || user?.role === ROLES.ADMIN;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [draggingLead, setDraggingLead] = useState(null);
|
||||
const [selectedLead, setSelectedLead] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
@@ -120,6 +117,9 @@ export default function KanbanPage() {
|
||||
// Campaign edit modal
|
||||
const [campaignEdit, setCampaignEdit] = useState(null); // { message, lead, column }
|
||||
|
||||
// Active drag — tracked so DragOverlay can render the floating clone
|
||||
const [draggingLead, setDraggingLead] = useState(null); // { lead, columnColor }
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
@@ -156,7 +156,9 @@ export default function KanbanPage() {
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||
setDraggingLead(lead ?? null);
|
||||
if (!lead) return;
|
||||
const col = kanbanColumns.find(c => c.id === lead.columnId);
|
||||
setDraggingLead({ lead, columnColor: col?.color ?? '#6366f1' });
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ active, over }) => {
|
||||
@@ -217,11 +219,17 @@ export default function KanbanPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={(args) => pointerWithin(args).length > 0 ? pointerWithin(args) : rectIntersection(args)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={`h-full flex flex-col bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Page header */}
|
||||
<div className="px-4 sm:px-6 py-5 border-b border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-950 sticky top-0 z-20">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0 px-4 sm:px-6 py-4 border-b border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-950 z-20">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
|
||||
<LayoutGrid size={16} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
@@ -230,12 +238,22 @@ export default function KanbanPage() {
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
Pipeline
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{kanbanLeads.length} leads across {stageColumns.length} stages</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{kanbanLeads.length} leads · {stageColumns.length} stages</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Stage — header shortcut for owners/admins */}
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:border-zinc-400 dark:hover:border-zinc-500 text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Plus size={12} /> Add Stage
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-xs ml-auto">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
@@ -252,15 +270,9 @@ export default function KanbanPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<div className="overflow-x-auto pb-8">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 px-4 sm:px-6 pt-5 min-w-max">
|
||||
{/* Board — fills remaining height, horizontal scroll only */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden min-h-0">
|
||||
<div className="flex gap-4 px-4 sm:px-6 py-5 min-w-max h-full">
|
||||
{/* Stage columns */}
|
||||
{stageColumns.map((col, i) => (
|
||||
<KanbanColumn
|
||||
@@ -278,8 +290,8 @@ export default function KanbanPage() {
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex flex-col gap-2 shrink-0 self-start pt-1">
|
||||
<div className="w-px h-full min-h-[200px] bg-zinc-200 dark:bg-zinc-800 mx-2" />
|
||||
<div className="shrink-0 self-stretch flex items-stretch px-2">
|
||||
<div className="w-px bg-zinc-200 dark:bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
{/* Bucket columns */}
|
||||
@@ -297,19 +309,7 @@ export default function KanbanPage() {
|
||||
isLast={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggingLead ? (
|
||||
<KanbanCard
|
||||
lead={draggingLead}
|
||||
columnColor={kanbanColumns.find(c => c.id === draggingLead.columnId)?.color ?? '#3B82F6'}
|
||||
isDragOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lead info drawer */}
|
||||
@@ -343,5 +343,18 @@ export default function KanbanPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* DragOverlay — dnd-kit already portals this to document.body internally,
|
||||
so position:fixed is always viewport-relative regardless of ancestors. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggingLead ? (
|
||||
<KanbanCardDisplay
|
||||
lead={draggingLead.lead}
|
||||
columnColor={draggingLead.columnColor}
|
||||
isOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
+983
-269
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
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
|
||||
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
|
||||
} from 'lucide-react';
|
||||
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||
@@ -68,8 +69,42 @@ const tabs = [
|
||||
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
||||
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
||||
{ id: 'activity', label: 'Activity', icon: Clock },
|
||||
{ id: 'docs', label: 'Docs', icon: FolderOpen },
|
||||
];
|
||||
|
||||
// ── Doc 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 seedDocsMock(projectId) {
|
||||
const all = [
|
||||
{ id: 'doc_1', name: 'Insurance Claim Form.pdf', uploadedBy: 'Sarah Owner', uploadedDate: '2026-01-15', size: '245 KB', notes: 'Original claim submitted. Claim #TX-9920-881.' },
|
||||
{ id: 'doc_2', name: 'Roof Inspection Report.pdf', uploadedBy: 'Frank Agent', uploadedDate: '2026-01-18', size: '1.2 MB', notes: 'Hail damage confirmed on north face. See page 7.' },
|
||||
{ id: 'doc_3', name: 'Adjuster Notes.docx', uploadedBy: 'Sarah Owner', uploadedDate: '2026-01-22', size: '38 KB', notes: '' },
|
||||
{ id: 'doc_4', name: 'Signed Contract.pdf', uploadedBy: 'Frank Agent', uploadedDate: '2026-02-01', size: '512 KB', notes: 'Fully executed. Watch payment schedule on page 4.' },
|
||||
{ id: 'doc_5', name: 'Before Photo - Front.jpg', uploadedBy: 'Frank Agent', uploadedDate: '2026-02-03', size: '3.1 MB', notes: 'Taken before work started.' },
|
||||
{ id: 'doc_6', name: 'Material Spec Sheet.md', uploadedBy: 'Sarah Owner', uploadedDate: '2026-02-05', size: '12 KB', notes: 'Owens Corning Duration — Class 4 IR.' },
|
||||
{ id: 'doc_7', name: 'Site Sketch.png', uploadedBy: 'Frank Agent', uploadedDate: '2026-02-08', size: '890 KB', notes: '' },
|
||||
{ id: 'doc_8', name: 'Work Order.txt', uploadedBy: 'Sarah Owner', uploadedDate: '2026-02-10', size: '4 KB', notes: 'Internal work order #WO-2026-044.' },
|
||||
];
|
||||
const seed = projectId.charCodeAt(projectId.length - 1) % 4;
|
||||
return all.slice(0, 4 + seed);
|
||||
}
|
||||
|
||||
const OwnerProjectDetail = () => {
|
||||
const { projectId } = useParams();
|
||||
const { user } = useAuth();
|
||||
@@ -80,6 +115,46 @@ const OwnerProjectDetail = () => {
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
const [createModalConfig, setCreateModalConfig] = useState(null);
|
||||
|
||||
// ── Docs state ────────────────────────────────────────────────────────────
|
||||
const [docs, setDocs] = useState(() => seedDocsMock(projectId));
|
||||
const [viewDoc, setViewDoc] = useState(null); // doc object being viewed
|
||||
const [editNoteDoc, setEditNoteDoc] = useState(null); // { doc, noteText }
|
||||
const [deleteDocId, setDeleteDocId] = useState(null); // id to confirm delete
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploadForm, setUploadForm] = useState({ name: '', type: 'pdf', notes: '' });
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDocUpload = () => {
|
||||
if (!uploadForm.name.trim()) return;
|
||||
const newDoc = {
|
||||
id: `doc_${Date.now()}`,
|
||||
name: uploadForm.name.trim().includes('.') ? uploadForm.name.trim() : `${uploadForm.name.trim()}.${uploadForm.type}`,
|
||||
uploadedBy: user?.name || 'Owner',
|
||||
uploadedDate: '2026-04-08',
|
||||
size: '—',
|
||||
notes: uploadForm.notes.trim(),
|
||||
};
|
||||
setDocs(prev => [newDoc, ...prev]);
|
||||
setUploadOpen(false);
|
||||
setUploadForm({ name: '', type: 'pdf', notes: '' });
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setUploadForm(prev => ({ ...prev, name: file.name }));
|
||||
};
|
||||
|
||||
const handleSaveNote = () => {
|
||||
if (!editNoteDoc) return;
|
||||
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 handleCreateSubmit = (data) => {
|
||||
console.log("Mock submitted data:", data);
|
||||
alert("Action successful! (Mock interface)");
|
||||
@@ -921,6 +996,110 @@ const OwnerProjectDetail = () => {
|
||||
)}
|
||||
|
||||
{/* ACTIVITY TAB */}
|
||||
{/* ── DOCS TAB ── */}
|
||||
{activeTab === 'docs' && (
|
||||
<NeoCard spotlightColor="rgba(59,130,246,0.1)">
|
||||
<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-white tracking-widest uppercase">Project Documents</h3>
|
||||
<span className="text-xs font-bold bg-white/5 border border-white/5 px-2 py-0.5 rounded-full 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-white">No documents yet</p>
|
||||
<p className="text-sm text-zinc-500 mt-1">Upload contracts, photos, inspection reports, and more.</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-white/[0.03] border border-white/[0.06] hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
{/* Type icon */}
|
||||
<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>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-semibold 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} · Uploaded by {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>
|
||||
|
||||
{/* Actions */}
|
||||
<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-white/5 hover:bg-white/10 text-zinc-400 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-white/5 hover:bg-white/10 text-zinc-400 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-white/5 hover:bg-red-500/20 text-zinc-400 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</NeoCard>
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
@@ -979,6 +1158,208 @@ const OwnerProjectDetail = () => {
|
||||
{...createModalConfig}
|
||||
/>
|
||||
)}
|
||||
{/* ── 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>
|
||||
|
||||
{/* Drop zone */}
|
||||
<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>
|
||||
<p className="text-xs text-zinc-600 mt-1">or drag and drop here</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" />
|
||||
|
||||
{/* Manual filename if no file selected */}
|
||||
{!uploadForm.name && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Or type filename (e.g. Contract.pdf)"
|
||||
value={uploadForm.name}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, 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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<textarea
|
||||
value={uploadForm.notes}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, 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 disabled:cursor-not-allowed 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-[160px]">
|
||||
<DocIcon size={48} 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 — file stored in backend.'); }}
|
||||
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(prev => ({ ...prev, 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>
|
||||
)}
|
||||
|
||||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import {
|
||||
Search, Filter, ChevronRight, ArrowUpDown, Briefcase,
|
||||
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert
|
||||
Search, ChevronRight, ArrowUpDown, Briefcase, LayoutGrid,
|
||||
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert, User
|
||||
} from 'lucide-react';
|
||||
|
||||
const statusConfig = {
|
||||
@@ -18,15 +18,51 @@ const statusConfig = {
|
||||
|
||||
const phaseOrder = ['Planning', 'Foundation', 'Structural', 'MEP', 'Finishing', 'Handover'];
|
||||
|
||||
// ---------- Pipeline lead helpers ----------
|
||||
const PIPELINE_STATUS_CONFIG = {
|
||||
active: { label: 'Active', color: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10', icon: Clock },
|
||||
completed: { label: 'Complete', color: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10', icon: CheckCircle },
|
||||
on_hold: { label: 'Stuck', color: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/10', icon: PauseCircle },
|
||||
delayed: { label: 'Follow-up', color: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10', icon: AlertTriangle },
|
||||
};
|
||||
|
||||
function getPipelineStatus(lead) {
|
||||
if (lead.bucketId === 'kc_stuck') return 'on_hold';
|
||||
if (lead.bucketId === 'kc_followup') return 'delayed';
|
||||
if (lead.columnId === 'kc_complete') return 'completed';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function getPipelineProgress(lead, stageColumns) {
|
||||
const idx = stageColumns.findIndex(c => c.id === lead.columnId);
|
||||
if (idx < 0 || stageColumns.length === 0) return 0;
|
||||
return Math.round(((idx + 1) / stageColumns.length) * 100);
|
||||
}
|
||||
|
||||
function getPipelineHealth(lead) {
|
||||
if (!lead.activity?.length) return 50;
|
||||
const days = Math.max(0, Math.floor((new Date('2026-04-07') - new Date(lead.activity[0].date + 'T00:00:00')) / 86400000));
|
||||
if (days < 7) return 90;
|
||||
if (days < 14) return 65;
|
||||
return 32;
|
||||
}
|
||||
|
||||
const OwnerProjectList = () => {
|
||||
const { user } = useAuth();
|
||||
const { projects, vendors } = useMockStore();
|
||||
const { projects, vendors, kanbanLeads, kanbanColumns } = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeSection, setActiveSection] = useState('construction'); // 'construction' | 'pipeline'
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [phaseFilter, setPhaseFilter] = useState('all');
|
||||
const [sortConfig, setSortConfig] = useState({ key: 'healthScore', direction: 'asc' });
|
||||
const [pipelineSearch, setPipelineSearch] = useState('');
|
||||
const [pipelineStatusFilter, setPipelineStatusFilter] = useState('all');
|
||||
|
||||
const stageColumns = useMemo(() =>
|
||||
[...(kanbanColumns || [])].filter(c => c.type === 'stage').sort((a, b) => a.order - b.order),
|
||||
[kanbanColumns]);
|
||||
|
||||
// Filter projects for this owner
|
||||
const ownerProjects = useMemo(() =>
|
||||
@@ -70,6 +106,21 @@ const OwnerProjectList = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// Pipeline leads filtering
|
||||
const filteredPipelineLeads = useMemo(() => {
|
||||
return (kanbanLeads || []).filter(l => {
|
||||
const q = pipelineSearch.toLowerCase();
|
||||
const matchSearch = !q ||
|
||||
l.name.toLowerCase().includes(q) ||
|
||||
l.address.toLowerCase().includes(q) ||
|
||||
l.jobType.toLowerCase().includes(q) ||
|
||||
(l.assignedAgentName || '').toLowerCase().includes(q);
|
||||
const status = getPipelineStatus(l);
|
||||
const matchStatus = pipelineStatusFilter === 'all' || status === pipelineStatusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
}, [kanbanLeads, pipelineSearch, pipelineStatusFilter]);
|
||||
|
||||
const formatCurrency = (amount) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount);
|
||||
|
||||
@@ -109,7 +160,7 @@ const OwnerProjectList = () => {
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
{ownerProjects.length} projects · {formatCurrency(totalBudget)} total budget · {formatCurrency(totalSpent)} spent
|
||||
{ownerProjects.length} construction · {(kanbanLeads || []).length} pipeline leads · {formatCurrency(totalBudget)} budget
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -120,6 +171,40 @@ const OwnerProjectList = () => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Section Toggle */}
|
||||
<div className="flex items-center gap-2 p-1 bg-zinc-100 dark:bg-white/5 rounded-xl w-fit border border-zinc-200 dark:border-white/10">
|
||||
<button
|
||||
onClick={() => setActiveSection('construction')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-colors ${
|
||||
activeSection === 'construction'
|
||||
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Briefcase size={14} />
|
||||
Construction
|
||||
<span className="text-[10px] font-black bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 px-1.5 py-0.5 rounded-full">
|
||||
{ownerProjects.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('pipeline')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-colors ${
|
||||
activeSection === 'pipeline'
|
||||
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Pipeline Leads
|
||||
<span className="text-[10px] font-black bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 px-1.5 py-0.5 rounded-full">
|
||||
{(kanbanLeads || []).length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── CONSTRUCTION SECTION ── */}
|
||||
{activeSection === 'construction' && (<>
|
||||
{/* Filters & Search */}
|
||||
<SpotlightCard className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
@@ -363,6 +448,193 @@ const OwnerProjectList = () => {
|
||||
</span>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</>)}
|
||||
|
||||
{/* ── PIPELINE SECTION ── */}
|
||||
{activeSection === 'pipeline' && (<>
|
||||
{/* Pipeline filters */}
|
||||
<SpotlightCard className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leads by name, address, job type, agent..."
|
||||
value={pipelineSearch}
|
||||
onChange={(e) => setPipelineSearch(e.target.value)}
|
||||
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[['all','All'], ['active','Active'], ['completed','Complete'], ['on_hold','Stuck'], ['delayed','Follow-up']].map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPipelineStatusFilter(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors ${pipelineStatusFilter === val
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
||||
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
{/* Pipeline table */}
|
||||
<SpotlightCard className="overflow-hidden">
|
||||
{/* Mobile card view */}
|
||||
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
|
||||
{filteredPipelineLeads.length > 0 ? filteredPipelineLeads.map(lead => {
|
||||
const status = getPipelineStatus(lead);
|
||||
const pcfg = PIPELINE_STATUS_CONFIG[status] || PIPELINE_STATUS_CONFIG.active;
|
||||
const PIcon = pcfg.icon;
|
||||
const pct = getPipelineProgress(lead, stageColumns);
|
||||
const stageName = stageColumns.find(c => c.id === lead.columnId)?.name ?? '—';
|
||||
const stageColor = stageColumns.find(c => c.id === lead.columnId)?.color ?? '#3B82F6';
|
||||
return (
|
||||
<div
|
||||
key={lead.id}
|
||||
onClick={() => navigate(`/owner/leads/${lead.id}`)}
|
||||
className="p-4 active:bg-zinc-50 dark:active:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<h4 className="font-semibold text-zinc-900 dark:text-white truncate">{lead.name}</h4>
|
||||
<p className="text-xs text-zinc-500 truncate mt-0.5">{lead.jobType} · {lead.address.split(',')[0]}</p>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-zinc-400 shrink-0 mt-1" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${pcfg.color}`}>
|
||||
<PIcon size={10} /> {pcfg.label}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide"
|
||||
style={{ backgroundColor: stageColor + '18', color: stageColor }}>
|
||||
{stageName}
|
||||
</span>
|
||||
{lead.assignedAgentName && (
|
||||
<span className="text-xs text-zinc-400">{lead.assignedAgentName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-zinc-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: stageColor }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-zinc-500 w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="py-16 text-center text-zinc-500">
|
||||
<LayoutGrid size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||
<p className="font-semibold">No pipeline leads match your filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||
<tr>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Lead</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Stage</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Job Type</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500">Agent</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 text-right">Progress</th>
|
||||
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 text-right">Health</th>
|
||||
<th className="px-5 py-4 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||
{filteredPipelineLeads.length > 0 ? filteredPipelineLeads.map(lead => {
|
||||
const status = getPipelineStatus(lead);
|
||||
const pcfg = PIPELINE_STATUS_CONFIG[status] || PIPELINE_STATUS_CONFIG.active;
|
||||
const PIcon = pcfg.icon;
|
||||
const pct = getPipelineProgress(lead, stageColumns);
|
||||
const health = getPipelineHealth(lead);
|
||||
const stageCol = stageColumns.find(c => c.id === lead.columnId);
|
||||
const stageName = stageCol?.name ?? '—';
|
||||
const stageColor = stageCol?.color ?? '#3B82F6';
|
||||
return (
|
||||
<tr
|
||||
key={lead.id}
|
||||
onClick={() => navigate(`/owner/leads/${lead.id}`)}
|
||||
className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-semibold text-zinc-900 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
|
||||
{lead.name}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 mt-0.5 max-w-xs truncate">{lead.address}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide ${pcfg.color}`}>
|
||||
<PIcon size={12} />
|
||||
{pcfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: stageColor + '18', color: stageColor }}>
|
||||
{stageName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{lead.jobType}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={12} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400 truncate max-w-[120px]">
|
||||
{lead.assignedAgentName ?? 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-1.5 bg-zinc-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: stageColor }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-zinc-600 dark:text-zinc-400 w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className={`text-sm font-bold ${getHealthColor(health)}`}>{health}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<ChevronRight size={16} className="text-zinc-400 group-hover:text-amber-500 transition-colors" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr>
|
||||
<td colSpan="8" className="py-20 text-center text-zinc-500">
|
||||
<LayoutGrid size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||
<p className="font-semibold">No pipeline leads match your filters.</p>
|
||||
<p className="text-sm mt-1">Try adjusting your search or filter criteria.</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 text-sm text-zinc-500 flex justify-between items-center">
|
||||
<span>Showing {filteredPipelineLeads.length} of {(kanbanLeads || []).length} leads</span>
|
||||
<button
|
||||
onClick={() => navigate('/owner/kanban')}
|
||||
className="flex items-center gap-1.5 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<LayoutGrid size={12} /> Open Pipeline Board
|
||||
</button>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</>)}
|
||||
|
||||
</div>
|
||||
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user