BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- Build complete Next.js CRM for commercial real estate
- Add authentication with JWT sessions and role-based access
- Add GoHighLevel API integration for contacts, conversations, opportunities
- Add AI-powered Control Center with tool calling
- Add Setup page with onboarding checklist (/setup)
- Add sidebar navigation with Setup menu item
- Fix type errors in onboarding API, GHL services, and control center tools
- Add Prisma schema with SQLite for local development
- Add UI components with clay morphism design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:30:55 -05:00

1415 lines
48 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
Plus,
DollarSign,
User,
MoreVertical,
Check,
X,
Trophy,
ChevronDown,
Edit2,
Trash2,
ArrowRight,
GripVertical,
Calendar,
TrendingUp,
Clock,
Filter,
AlertCircle,
Target,
XCircle
} from 'lucide-react';
// Types
interface Pipeline {
id: string;
name: string;
stages: Stage[];
}
interface Stage {
id: string;
name: string;
order: number;
color: string;
}
interface Contact {
id: string;
name: string;
email: string;
company?: string;
}
interface Opportunity {
id: string;
name: string;
value: number;
stageId: string;
pipelineId: string;
contact: Contact;
status: 'open' | 'won' | 'lost';
createdAt: string;
updatedAt: string;
closingDate?: string;
probability?: number;
priority?: 'low' | 'medium' | 'high';
lastActivityDate?: string;
}
// Mock data
const mockPipelines: Pipeline[] = [
{
id: 'pipeline-1',
name: 'Sales Pipeline',
stages: [
{ id: 'stage-1', name: 'Lead', order: 1, color: '#6366f1' },
{ id: 'stage-2', name: 'Qualified', order: 2, color: '#8b5cf6' },
{ id: 'stage-3', name: 'Proposal', order: 3, color: '#f59e0b' },
{ id: 'stage-4', name: 'Negotiation', order: 4, color: '#f97316' },
{ id: 'stage-5', name: 'Closed Won', order: 5, color: '#22c55e' },
{ id: 'stage-6', name: 'Closed Lost', order: 6, color: '#ef4444' },
],
},
{
id: 'pipeline-2',
name: 'Property Acquisition',
stages: [
{ id: 'stage-7', name: 'Initial Contact', order: 1, color: '#6366f1' },
{ id: 'stage-8', name: 'Due Diligence', order: 2, color: '#8b5cf6' },
{ id: 'stage-9', name: 'Offer Made', order: 3, color: '#f59e0b' },
{ id: 'stage-10', name: 'Under Contract', order: 4, color: '#f97316' },
{ id: 'stage-11', name: 'Closed', order: 5, color: '#22c55e' },
{ id: 'stage-12', name: 'Closed Lost', order: 6, color: '#ef4444' },
],
},
];
const mockOpportunities: Opportunity[] = [
{
id: 'opp-1',
name: 'Downtown Office Building',
value: 2500000,
stageId: 'stage-2',
pipelineId: 'pipeline-1',
contact: { id: 'c1', name: 'John Smith', email: 'john@example.com', company: 'Smith Properties' },
status: 'open',
createdAt: '2024-01-15',
updatedAt: '2024-01-20',
closingDate: '2024-03-15',
probability: 60,
priority: 'high',
lastActivityDate: '2024-01-20',
},
{
id: 'opp-2',
name: 'Retail Space Lease',
value: 180000,
stageId: 'stage-1',
pipelineId: 'pipeline-1',
contact: { id: 'c2', name: 'Sarah Johnson', email: 'sarah@retail.com', company: 'Retail Corp' },
status: 'open',
createdAt: '2024-01-18',
updatedAt: '2024-01-18',
closingDate: '2024-04-01',
probability: 25,
priority: 'low',
lastActivityDate: '2024-01-18',
},
{
id: 'opp-3',
name: 'Industrial Warehouse',
value: 4200000,
stageId: 'stage-3',
pipelineId: 'pipeline-1',
contact: { id: 'c3', name: 'Mike Wilson', email: 'mike@logistics.com', company: 'Logistics Inc' },
status: 'open',
createdAt: '2024-01-10',
updatedAt: '2024-01-22',
closingDate: '2024-02-28',
probability: 75,
priority: 'high',
lastActivityDate: '2024-01-22',
},
{
id: 'opp-4',
name: 'Suburban Office Park',
value: 1800000,
stageId: 'stage-4',
pipelineId: 'pipeline-1',
contact: { id: 'c4', name: 'Emily Brown', email: 'emily@tech.com', company: 'Tech Startup' },
status: 'open',
createdAt: '2024-01-05',
updatedAt: '2024-01-21',
closingDate: '2024-02-15',
probability: 85,
priority: 'medium',
lastActivityDate: '2024-01-21',
},
{
id: 'opp-5',
name: 'Mixed-Use Development',
value: 8500000,
stageId: 'stage-1',
pipelineId: 'pipeline-1',
contact: { id: 'c5', name: 'David Lee', email: 'david@dev.com', company: 'Development Group' },
status: 'open',
createdAt: '2024-01-22',
updatedAt: '2024-01-22',
closingDate: '2024-06-30',
probability: 20,
priority: 'medium',
lastActivityDate: '2024-01-22',
},
{
id: 'opp-6',
name: 'Failed Strip Mall Deal',
value: 950000,
stageId: 'stage-6',
pipelineId: 'pipeline-1',
contact: { id: 'c2', name: 'Sarah Johnson', email: 'sarah@retail.com', company: 'Retail Corp' },
status: 'lost',
createdAt: '2023-12-01',
updatedAt: '2024-01-10',
closingDate: '2024-01-10',
probability: 0,
priority: 'low',
lastActivityDate: '2024-01-10',
},
];
const mockContacts: Contact[] = [
{ id: 'c1', name: 'John Smith', email: 'john@example.com', company: 'Smith Properties' },
{ id: 'c2', name: 'Sarah Johnson', email: 'sarah@retail.com', company: 'Retail Corp' },
{ id: 'c3', name: 'Mike Wilson', email: 'mike@logistics.com', company: 'Logistics Inc' },
{ id: 'c4', name: 'Emily Brown', email: 'emily@tech.com', company: 'Tech Startup' },
{ id: 'c5', name: 'David Lee', email: 'david@dev.com', company: 'Development Group' },
];
// Helper function to format currency
const formatCurrency = (value: number): string => {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `$${(value / 1000).toFixed(0)}K`;
}
return `$${value}`;
};
// Helper function to format date
const formatDate = (dateString?: string): string => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
// Helper to calculate days until closing
const getDaysUntilClosing = (closingDate?: string): number | null => {
if (!closingDate) return null;
const today = new Date();
const closing = new Date(closingDate);
const diffTime = closing.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
// Helper to get urgency color
const getUrgencyColor = (daysUntilClosing: number | null, priority?: string): string => {
if (priority === 'high') return 'border-l-red-500';
if (daysUntilClosing !== null && daysUntilClosing <= 7) return 'border-l-red-500';
if (daysUntilClosing !== null && daysUntilClosing <= 14) return 'border-l-amber-500';
if (priority === 'medium') return 'border-l-amber-500';
return 'border-l-blue-500';
};
// Priority badge component
const PriorityBadge: React.FC<{ priority?: string }> = ({ priority }) => {
if (!priority) return null;
const colors = {
high: 'bg-red-100 text-red-700 border-red-200',
medium: 'bg-amber-100 text-amber-700 border-amber-200',
low: 'bg-blue-100 text-blue-700 border-blue-200',
};
return (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase border ${colors[priority as keyof typeof colors] || colors.low}`}>
{priority}
</span>
);
};
// Progress bar for deal stage
const StageProgress: React.FC<{ currentOrder: number; totalStages: number; color: string }> = ({
currentOrder,
totalStages,
color
}) => {
// Exclude closed lost from progress calculation (it's not a progression)
const progressStages = totalStages - 1;
const progress = Math.min((currentOrder / progressStages) * 100, 100);
return (
<div className="w-full h-1.5 bg-muted/50 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${progress}%`, backgroundColor: color }}
/>
</div>
);
};
// Pipeline Selector Component
const PipelineSelector: React.FC<{
pipelines: Pipeline[];
selectedPipeline: Pipeline;
onSelect: (pipeline: Pipeline) => void;
}> = ({ pipelines, selectedPipeline, onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (pipelines.length <= 1) return null;
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="clay-btn flex items-center gap-2 px-4 py-2.5 text-foreground font-medium"
>
{selectedPipeline.name}
<ChevronDown size={18} className={`transition-transform text-muted-foreground ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-56 clay-card p-2 z-50 shadow-lg border border-border/50">
{pipelines.map((pipeline) => (
<button
key={pipeline.id}
onClick={() => {
onSelect(pipeline);
setIsOpen(false);
}}
className={`w-full px-4 py-2.5 rounded-lg text-left transition-colors ${
pipeline.id === selectedPipeline.id
? 'bg-primary/10 text-primary font-semibold'
: 'text-foreground hover:bg-muted'
}`}
>
{pipeline.name}
</button>
))}
</div>
)}
</div>
);
};
// Quick Filter Component
type FilterType = 'all' | 'high-value' | 'closing-soon' | 'high-priority';
const QuickFilters: React.FC<{
activeFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}> = ({ activeFilter, onFilterChange }) => {
const filters: { key: FilterType; label: string; icon: React.ReactNode }[] = [
{ key: 'all', label: 'All Deals', icon: <Target size={14} /> },
{ key: 'high-value', label: 'High Value', icon: <DollarSign size={14} /> },
{ key: 'closing-soon', label: 'Closing Soon', icon: <Clock size={14} /> },
{ key: 'high-priority', label: 'High Priority', icon: <AlertCircle size={14} /> },
];
return (
<div className="flex items-center gap-3">
<Filter size={16} className="text-muted-foreground" />
<div className="flex items-center gap-2 bg-muted/30 rounded-lg p-1.5">
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => onFilterChange(filter.key)}
className={`flex items-center gap-2 px-5 py-3 rounded-md text-xs font-medium transition-all ${
activeFilter === filter.key
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{filter.icon}
{filter.label}
</button>
))}
</div>
</div>
);
};
// Stage Move Menu Component
const StageMoveMenu: React.FC<{
stages: Stage[];
currentStageId: string;
onMove: (stageId: string) => void;
onClose: () => void;
}> = ({ stages, currentStageId, onMove, onClose }) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
return (
<div
ref={menuRef}
className="absolute right-0 top-full mt-2 w-48 clay-card p-2 shadow-lg border border-border/50 z-50"
>
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide border-b border-border/50 mb-1">
Move to Stage
</div>
{stages.map((stage) => (
<button
key={stage.id}
onClick={() => {
onMove(stage.id);
onClose();
}}
disabled={stage.id === currentStageId}
className={`w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 transition-colors ${
stage.id === currentStageId
? 'bg-muted/50 text-muted-foreground cursor-not-allowed'
: 'hover:bg-muted text-foreground'
}`}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: stage.color }}
/>
{stage.name}
{stage.id === currentStageId && (
<Check size={14} className="ml-auto text-muted-foreground" />
)}
</button>
))}
</div>
);
};
// Opportunity Card Quick Actions Menu
const QuickActionsMenu: React.FC<{
opportunity: Opportunity;
stages: Stage[];
onMarkWon: () => void;
onMarkLost: () => void;
onEdit: () => void;
onDelete: () => void;
onMove: (stageId: string) => void;
onClose: () => void;
}> = ({ opportunity, stages, onMarkWon, onMarkLost, onEdit, onDelete, onMove, onClose }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [showMoveMenu, setShowMoveMenu] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
return (
<div
ref={menuRef}
className="absolute right-0 top-full mt-2 w-48 clay-card p-2 shadow-lg border border-border/50 z-50"
>
{opportunity.status === 'open' && (
<>
<button
onClick={onMarkWon}
className="w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 hover:bg-green-50 text-green-600 transition-colors"
>
<Trophy size={16} />
Mark as Won
</button>
<button
onClick={onMarkLost}
className="w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 hover:bg-red-50 text-red-600 transition-colors"
>
<X size={16} />
Mark as Lost
</button>
<div className="relative">
<button
onClick={() => setShowMoveMenu(!showMoveMenu)}
className="w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 hover:bg-muted text-foreground transition-colors"
>
<ArrowRight size={16} />
Move to Stage
<ChevronDown size={14} className="ml-auto text-muted-foreground" />
</button>
{showMoveMenu && (
<StageMoveMenu
stages={stages}
currentStageId={opportunity.stageId}
onMove={onMove}
onClose={() => setShowMoveMenu(false)}
/>
)}
</div>
<div className="border-t border-border/50 my-1" />
</>
)}
<button
onClick={onEdit}
className="w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 hover:bg-muted text-foreground transition-colors"
>
<Edit2 size={16} />
Edit
</button>
<button
onClick={onDelete}
className="w-full px-3 py-2 rounded-lg text-left flex items-center gap-2 hover:bg-red-50 text-red-600 transition-colors"
>
<Trash2 size={16} />
Delete
</button>
</div>
);
};
// Opportunity Card Component
const OpportunityCard: React.FC<{
opportunity: Opportunity;
stages: Stage[];
onUpdate: (id: string, updates: Partial<Opportunity>) => void;
onDelete: (id: string) => void;
onEdit: (opportunity: Opportunity) => void;
isDragging?: boolean;
}> = ({ opportunity, stages, onUpdate, onDelete, onEdit, isDragging = false }) => {
const [showActions, setShowActions] = useState(false);
const daysUntilClosing = getDaysUntilClosing(opportunity.closingDate);
const urgencyColor = getUrgencyColor(daysUntilClosing, opportunity.priority);
const currentStage = stages.find(s => s.id === opportunity.stageId);
const stageOrder = currentStage?.order || 1;
const handleMarkWon = () => {
const wonStage = stages.find(s => s.name.toLowerCase().includes('won'));
onUpdate(opportunity.id, { status: 'won', stageId: wonStage?.id || opportunity.stageId });
setShowActions(false);
};
const handleMarkLost = () => {
const lostStage = stages.find(s => s.name.toLowerCase().includes('lost'));
onUpdate(opportunity.id, { status: 'lost', stageId: lostStage?.id || opportunity.stageId });
setShowActions(false);
};
const handleMove = (stageId: string) => {
onUpdate(opportunity.id, { stageId });
setShowActions(false);
};
return (
<div
className={`clay-card shadow-lg border border-border/50 p-6 transition-all duration-200 cursor-grab group
border-l-4 ${urgencyColor}
${isDragging
? 'shadow-2xl scale-105 rotate-2 opacity-90 ring-2 ring-primary/50'
: 'hover:shadow-xl hover:-translate-y-0.5'
}
`}
>
{/* Drag Handle */}
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0 pt-0.5 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing">
<GripVertical size={16} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-foreground truncate text-sm mb-3">
{opportunity.name}
</h4>
<div className="flex items-center gap-3 mt-1">
<User size={12} className="text-muted-foreground flex-shrink-0" />
<span className="text-xs text-slate-500 truncate">
{opportunity.contact.name}
</span>
</div>
{opportunity.contact.company && (
<p className="text-xs text-slate-400 truncate mt-1 ml-[24px]">
{opportunity.contact.company}
</p>
)}
</div>
<div className="relative flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
setShowActions(!showActions);
}}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label="More options"
>
<MoreVertical size={16} />
</button>
{showActions && (
<QuickActionsMenu
opportunity={opportunity}
stages={stages}
onMarkWon={handleMarkWon}
onMarkLost={handleMarkLost}
onEdit={() => {
onEdit(opportunity);
setShowActions(false);
}}
onDelete={() => {
onDelete(opportunity.id);
setShowActions(false);
}}
onMove={handleMove}
onClose={() => setShowActions(false)}
/>
)}
</div>
</div>
</div>
</div>
{/* Value and Priority Row */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<DollarSign size={14} className="text-green-600" />
<span className="font-bold text-foreground">
{formatCurrency(opportunity.value)}
</span>
</div>
<PriorityBadge priority={opportunity.priority} />
</div>
{/* Metadata Row */}
<div className="grid grid-cols-2 gap-3 mb-4 text-xs">
<div className="flex items-center gap-3 text-slate-500">
<Calendar size={12} className="flex-shrink-0" />
<span className="truncate">
{opportunity.closingDate
? `Close: ${formatDate(opportunity.closingDate)}`
: 'No close date'
}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<Clock size={12} className="flex-shrink-0" />
<span className="truncate">
{opportunity.lastActivityDate
? `Last: ${formatDate(opportunity.lastActivityDate)}`
: 'No activity'
}
</span>
</div>
</div>
{/* Probability and Progress */}
{opportunity.status === 'open' && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500 flex items-center gap-3">
<TrendingUp size={12} />
Probability
</span>
<span className="font-semibold text-foreground">
{opportunity.probability || 0}%
</span>
</div>
<StageProgress
currentOrder={stageOrder}
totalStages={stages.length}
color={currentStage?.color || '#6366f1'}
/>
</div>
)}
{/* Status indicator for won/lost */}
{opportunity.status !== 'open' && (
<div className={`flex items-center justify-center gap-2 py-2 rounded-lg mt-2 ${
opportunity.status === 'won'
? 'bg-green-50 text-green-700'
: 'bg-red-50 text-red-700'
}`}>
{opportunity.status === 'won' ? (
<>
<Trophy size={14} />
<span className="text-xs font-semibold uppercase">Deal Won</span>
</>
) : (
<>
<XCircle size={14} />
<span className="text-xs font-semibold uppercase">Deal Lost</span>
</>
)}
</div>
)}
</div>
);
};
// Empty State Component
const EmptyColumnState: React.FC<{
stageName: string;
stageColor: string;
isClosedStage?: boolean;
}> = ({ stageName, stageColor, isClosedStage }) => {
return (
<div className="text-center py-12 px-6">
<div
className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center shadow-inner"
style={{ backgroundColor: `${stageColor}15` }}
>
{isClosedStage ? (
stageName.toLowerCase().includes('won') ? (
<Trophy className="text-green-500" size={24} />
) : (
<XCircle className="text-red-400" size={24} />
)
) : (
<GripVertical style={{ color: stageColor }} size={24} />
)}
</div>
<p className="text-slate-500 text-sm font-medium mb-2">
No deals in {stageName}
</p>
<p className="text-slate-400 text-xs">
{isClosedStage
? stageName.toLowerCase().includes('won')
? 'Close deals to see them here'
: 'Lost deals will appear here'
: 'Drag deals here or add new ones'
}
</p>
</div>
);
};
// Stage Column Component
const StageColumn: React.FC<{
stage: Stage;
opportunities: Opportunity[];
allStages: Stage[];
onUpdateOpportunity: (id: string, updates: Partial<Opportunity>) => void;
onDeleteOpportunity: (id: string) => void;
onEditOpportunity: (opportunity: Opportunity) => void;
onDragStart: (opportunityId: string) => void;
onDragEnd: () => void;
onDrop: (stageId: string) => void;
draggingId: string | null;
}> = ({
stage,
opportunities,
allStages,
onUpdateOpportunity,
onDeleteOpportunity,
onEditOpportunity,
onDragStart,
onDragEnd,
onDrop,
draggingId
}) => {
const [isDragOver, setIsDragOver] = useState(false);
const stageOpportunities = opportunities.filter(opp => opp.stageId === stage.id);
const totalValue = stageOpportunities.reduce((sum, opp) => sum + opp.value, 0);
const isClosedStage = stage.name.toLowerCase().includes('closed') || stage.name.toLowerCase().includes('lost') || stage.name.toLowerCase().includes('won');
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
onDrop(stage.id);
};
return (
<div
className="min-w-[320px] max-w-[320px] flex flex-col h-full"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Stage Header */}
<div className="clay-card shadow-lg border border-border/50 p-5 mb-5">
<div className="flex items-center gap-4 mb-3">
<div
className="w-3.5 h-3.5 rounded-full ring-2 ring-offset-2 ring-offset-background"
style={{ backgroundColor: stage.color, boxShadow: `0 0 8px ${stage.color}40` }}
/>
<h3 className="font-bold text-foreground text-base tracking-tight">{stage.name}</h3>
<span
className="ml-auto text-xs font-bold px-3 py-1.5 rounded-full"
style={{
backgroundColor: `${stage.color}20`,
color: stage.color
}}
>
{stageOpportunities.length}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500">
<DollarSign size={14} className="text-green-600" />
<span className="font-semibold text-foreground">{formatCurrency(totalValue)}</span>
</div>
</div>
{/* Cards Container */}
<div
className={`clay-card-pressed flex-1 p-4 rounded-xl border transition-all duration-200 overflow-hidden ${
isDragOver
? 'border-primary bg-primary/5 ring-2 ring-primary/30'
: 'border-border/30'
}`}
>
<div className="h-full space-y-4 overflow-y-auto pr-1 pb-2">
{stageOpportunities.length === 0 ? (
<EmptyColumnState
stageName={stage.name}
stageColor={stage.color}
isClosedStage={isClosedStage}
/>
) : (
stageOpportunities.map((opportunity) => (
<div
key={opportunity.id}
draggable
onDragStart={() => onDragStart(opportunity.id)}
onDragEnd={onDragEnd}
>
<OpportunityCard
opportunity={opportunity}
stages={allStages}
onUpdate={onUpdateOpportunity}
onDelete={onDeleteOpportunity}
onEdit={onEditOpportunity}
isDragging={draggingId === opportunity.id}
/>
</div>
))
)}
</div>
</div>
</div>
);
};
// Add/Edit Opportunity Modal
const OpportunityModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSave: (opportunity: Partial<Opportunity>) => void;
opportunity?: Opportunity | null;
stages: Stage[];
contacts: Contact[];
pipelineId: string;
}> = ({ isOpen, onClose, onSave, opportunity, stages, contacts, pipelineId }) => {
const [formData, setFormData] = useState({
name: '',
value: '',
stageId: stages[0]?.id || '',
contactId: '',
status: 'open' as 'open' | 'won' | 'lost',
closingDate: '',
probability: '50',
priority: 'medium' as 'low' | 'medium' | 'high',
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (opportunity) {
setFormData({
name: opportunity.name,
value: opportunity.value.toString(),
stageId: opportunity.stageId,
contactId: opportunity.contact.id,
status: opportunity.status,
closingDate: opportunity.closingDate || '',
probability: (opportunity.probability || 50).toString(),
priority: opportunity.priority || 'medium',
});
} else {
setFormData({
name: '',
value: '',
stageId: stages[0]?.id || '',
contactId: '',
status: 'open',
closingDate: '',
probability: '50',
priority: 'medium',
});
}
setErrors({});
}, [opportunity, stages, isOpen]);
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) newErrors.name = 'Name is required';
if (!formData.value || parseFloat(formData.value) <= 0) newErrors.value = 'Valid value is required';
if (!formData.contactId) newErrors.contactId = 'Contact is required';
if (!formData.stageId) newErrors.stageId = 'Stage is required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
const selectedContact = contacts.find(c => c.id === formData.contactId);
if (!selectedContact) return;
onSave({
id: opportunity?.id,
name: formData.name,
value: parseFloat(formData.value),
stageId: formData.stageId,
pipelineId,
contact: selectedContact,
status: formData.status,
closingDate: formData.closingDate || undefined,
probability: parseInt(formData.probability),
priority: formData.priority,
lastActivityDate: new Date().toISOString().split('T')[0],
});
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="clay-card w-full max-w-md shadow-xl border border-border/50 overflow-hidden max-h-[90vh] overflow-y-auto">
<div className="px-8 py-5 border-b border-border/50 flex items-center justify-between sticky top-0 bg-background z-10">
<h2 className="text-lg font-bold text-foreground">
{opportunity ? 'Edit Opportunity' : 'Add Opportunity'}
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
{/* Name */}
<div>
<label className="block text-sm font-semibold text-foreground mb-2">
Opportunity Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`clay-input ${
errors.name ? 'ring-2 ring-red-400' : ''
}`}
placeholder="e.g., Downtown Office Building"
/>
{errors.name && <p className="text-red-500 text-xs mt-1.5">{errors.name}</p>}
</div>
{/* Value */}
<div>
<label className="block text-sm font-semibold text-foreground mb-2">
Value ($)
</label>
<div className="relative">
<DollarSign size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="number"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
className={`clay-input clay-input-icon ${
errors.value ? 'ring-2 ring-red-400' : ''
}`}
placeholder="0"
min="0"
step="1000"
/>
</div>
{errors.value && <p className="text-red-500 text-xs mt-1.5">{errors.value}</p>}
</div>
{/* Contact */}
<div>
<label className="block text-sm font-semibold text-foreground mb-2">
Contact
</label>
<select
value={formData.contactId}
onChange={(e) => setFormData({ ...formData, contactId: e.target.value })}
className={`clay-input bg-transparent ${
errors.contactId ? 'ring-2 ring-red-400' : ''
}`}
>
<option value="">Select a contact</option>
{contacts.map((contact) => (
<option key={contact.id} value={contact.id}>
{contact.name} {contact.company ? `(${contact.company})` : ''}
</option>
))}
</select>
{errors.contactId && <p className="text-red-500 text-xs mt-1.5">{errors.contactId}</p>}
</div>
{/* Stage */}
<div>
<label className="block text-sm font-semibold text-foreground mb-2">
Stage
</label>
<select
value={formData.stageId}
onChange={(e) => setFormData({ ...formData, stageId: e.target.value })}
className={`clay-input bg-transparent ${
errors.stageId ? 'ring-2 ring-red-400' : ''
}`}
>
{stages.map((stage) => (
<option key={stage.id} value={stage.id}>
{stage.name}
</option>
))}
</select>
{errors.stageId && <p className="text-red-500 text-xs mt-1.5">{errors.stageId}</p>}
</div>
{/* Closing Date */}
<div>
<label className="block text-sm font-semibold text-foreground mb-2">
Expected Close Date
</label>
<input
type="date"
value={formData.closingDate}
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
className="clay-input"
/>
</div>
{/* Probability */}
<div>
<label className="block text-sm font-semibold text-foreground mb-2">
Win Probability: {formData.probability}%
</label>
<input
type="range"
min="0"
max="100"
step="5"
value={formData.probability}
onChange={(e) => setFormData({ ...formData, probability: e.target.value })}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-semibold text-foreground mb-3">
Priority
</label>
<div className="flex gap-3">
{(['low', 'medium', 'high'] as const).map((priority) => (
<button
key={priority}
type="button"
onClick={() => setFormData({ ...formData, priority })}
className={`flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all border ${
formData.priority === priority
? priority === 'high'
? 'bg-red-100 text-red-700 border-red-300'
: priority === 'medium'
? 'bg-amber-100 text-amber-700 border-amber-300'
: 'bg-blue-100 text-blue-700 border-blue-300'
: 'bg-muted/30 text-muted-foreground border-border hover:bg-muted'
}`}
>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</button>
))}
</div>
</div>
{/* Status (only for editing) */}
{opportunity && (
<div>
<label className="block text-sm font-semibold text-foreground mb-3">
Status
</label>
<div className="flex gap-3">
{(['open', 'won', 'lost'] as const).map((status) => (
<button
key={status}
type="button"
onClick={() => setFormData({ ...formData, status })}
className={`flex-1 py-3 px-4 rounded-xl text-sm font-semibold transition-all ${
formData.status === status
? status === 'open'
? 'bg-blue-100 text-blue-700 ring-2 ring-blue-300'
: status === 'won'
? 'bg-green-100 text-green-700 ring-2 ring-green-300'
: 'bg-red-100 text-red-700 ring-2 ring-red-300'
: 'clay-btn text-muted-foreground hover:text-foreground'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</button>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-4 pt-6 mt-2">
<button
type="button"
onClick={onClose}
className="flex-1 clay-btn py-3 px-5 font-semibold text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
className="flex-1 clay-btn-primary py-3 px-5"
>
{opportunity ? 'Save Changes' : 'Add Opportunity'}
</button>
</div>
</form>
</div>
</div>
);
};
// Delete Confirmation Modal
const DeleteConfirmModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
opportunityName: string;
}> = ({ isOpen, onClose, onConfirm, opportunityName }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="clay-card w-full max-w-sm shadow-xl border border-border/50 p-6">
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 flex items-center justify-center shadow-inner">
<Trash2 className="text-red-600" size={26} />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">Delete Opportunity</h3>
<p className="text-muted-foreground text-sm">
Are you sure you want to delete &quot;{opportunityName}&quot;? This action cannot be undone.
</p>
</div>
<div className="flex gap-4">
<button
onClick={onClose}
className="flex-1 clay-btn py-2.5 px-4 font-semibold text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
onClick={onConfirm}
className="flex-1 py-2.5 px-4 rounded-xl font-semibold text-white bg-red-600 hover:bg-red-700 transition-colors shadow-lg"
>
Delete
</button>
</div>
</div>
</div>
);
};
// Main Page Component
export default function OpportunitiesPage() {
const [pipelines] = useState<Pipeline[]>(mockPipelines);
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline>(mockPipelines[0]);
const [opportunities, setOpportunities] = useState<Opportunity[]>(mockOpportunities);
const [contacts] = useState<Contact[]>(mockContacts);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingOpportunity, setEditingOpportunity] = useState<Opportunity | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string; name: string }>({
isOpen: false,
id: '',
name: '',
});
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [draggingId, setDraggingId] = useState<string | null>(null);
// Filter opportunities based on active filter
const filterOpportunities = (opps: Opportunity[]): Opportunity[] => {
const pipelineOpps = opps.filter((opp) => opp.pipelineId === selectedPipeline.id);
switch (activeFilter) {
case 'high-value':
return pipelineOpps.filter(opp => opp.value >= 1000000);
case 'closing-soon':
return pipelineOpps.filter(opp => {
const days = getDaysUntilClosing(opp.closingDate);
return days !== null && days <= 30 && days >= 0;
});
case 'high-priority':
return pipelineOpps.filter(opp => opp.priority === 'high');
default:
return pipelineOpps;
}
};
const pipelineOpportunities = filterOpportunities(opportunities);
const allPipelineOpportunities = opportunities.filter(
(opp) => opp.pipelineId === selectedPipeline.id
);
const totalPipelineValue = allPipelineOpportunities
.filter((opp) => opp.status === 'open')
.reduce((sum, opp) => sum + opp.value, 0);
const openDealsCount = allPipelineOpportunities.filter(o => o.status === 'open').length;
const wonDealsCount = allPipelineOpportunities.filter(o => o.status === 'won').length;
const lostDealsCount = allPipelineOpportunities.filter(o => o.status === 'lost').length;
const handleUpdateOpportunity = (id: string, updates: Partial<Opportunity>) => {
setOpportunities((prev) =>
prev.map((opp) =>
opp.id === id ? { ...opp, ...updates, updatedAt: new Date().toISOString() } : opp
)
);
};
const handleDeleteOpportunity = (id: string) => {
const opportunity = opportunities.find((opp) => opp.id === id);
if (opportunity) {
setDeleteConfirm({ isOpen: true, id, name: opportunity.name });
}
};
const confirmDelete = () => {
setOpportunities((prev) => prev.filter((opp) => opp.id !== deleteConfirm.id));
setDeleteConfirm({ isOpen: false, id: '', name: '' });
};
const handleSaveOpportunity = (data: Partial<Opportunity>) => {
if (data.id) {
// Update existing
handleUpdateOpportunity(data.id, data);
} else {
// Create new
const newOpportunity: Opportunity = {
id: `opp-${Date.now()}`,
name: data.name || '',
value: data.value || 0,
stageId: data.stageId || selectedPipeline.stages[0].id,
pipelineId: selectedPipeline.id,
contact: data.contact!,
status: 'open',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
closingDate: data.closingDate,
probability: data.probability,
priority: data.priority,
lastActivityDate: new Date().toISOString().split('T')[0],
};
setOpportunities((prev) => [...prev, newOpportunity]);
}
setEditingOpportunity(null);
};
const handleEditOpportunity = (opportunity: Opportunity) => {
setEditingOpportunity(opportunity);
setIsModalOpen(true);
};
const handleAddNew = () => {
setEditingOpportunity(null);
setIsModalOpen(true);
};
const handleDragStart = (opportunityId: string) => {
setDraggingId(opportunityId);
};
const handleDragEnd = () => {
setDraggingId(null);
};
const handleDrop = (stageId: string) => {
if (draggingId) {
handleUpdateOpportunity(draggingId, { stageId });
setDraggingId(null);
}
};
return (
<div className="h-full flex flex-col space-y-8">
{/* Header - Level 1 (subtle) since it's a page header */}
<div className="mb-6">
<div className="clay-card-subtle p-6 border border-border/50 mb-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<h2 className="text-2xl font-bold text-foreground tracking-tight">Pipeline</h2>
<p className="text-slate-500 mt-1">
Manage and track your deals through the sales process
</p>
</div>
</div>
<div className="flex items-center gap-4">
<PipelineSelector
pipelines={pipelines}
selectedPipeline={selectedPipeline}
onSelect={setSelectedPipeline}
/>
<button
onClick={handleAddNew}
className="clay-btn-primary inline-flex items-center gap-2 px-5 py-2.5"
>
<Plus size={18} />
Add Opportunity
</button>
</div>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<div className="clay-card p-5 border border-border/50">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-green-100 flex items-center justify-center">
<DollarSign className="text-green-600" size={22} />
</div>
<div>
<p className="text-xs text-slate-500 font-medium uppercase tracking-wide mb-1">Total Pipeline</p>
<p className="text-xl font-bold text-foreground">{formatCurrency(totalPipelineValue)}</p>
</div>
</div>
</div>
<div className="clay-card p-5 border border-border/50">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
<Target className="text-blue-600" size={22} />
</div>
<div>
<p className="text-xs text-slate-500 font-medium uppercase tracking-wide mb-1">Open Deals</p>
<p className="text-xl font-bold text-foreground">{openDealsCount}</p>
</div>
</div>
</div>
<div className="clay-card p-5 border border-border/50">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-green-100 flex items-center justify-center">
<Trophy className="text-green-600" size={22} />
</div>
<div>
<p className="text-xs text-slate-500 font-medium uppercase tracking-wide mb-1">Won</p>
<p className="text-xl font-bold text-foreground">{wonDealsCount}</p>
</div>
</div>
</div>
<div className="clay-card p-5 border border-border/50">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-red-100 flex items-center justify-center">
<XCircle className="text-red-600" size={22} />
</div>
<div>
<p className="text-xs text-slate-500 font-medium uppercase tracking-wide mb-1">Lost</p>
<p className="text-xl font-bold text-foreground">{lostDealsCount}</p>
</div>
</div>
</div>
</div>
{/* Quick Filters */}
<QuickFilters
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
/>
</div>
{/* Kanban Board */}
<div className="flex-1 overflow-hidden">
<div className="h-full overflow-x-auto pb-6">
<div className="flex gap-8 h-full min-w-max">
{selectedPipeline.stages
.sort((a, b) => a.order - b.order)
.map((stage) => (
<StageColumn
key={stage.id}
stage={stage}
opportunities={pipelineOpportunities}
allStages={selectedPipeline.stages}
onUpdateOpportunity={handleUpdateOpportunity}
onDeleteOpportunity={handleDeleteOpportunity}
onEditOpportunity={handleEditOpportunity}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
draggingId={draggingId}
/>
))}
</div>
</div>
</div>
{/* Add/Edit Modal */}
<OpportunityModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setEditingOpportunity(null);
}}
onSave={handleSaveOpportunity}
opportunity={editingOpportunity}
stages={selectedPipeline.stages}
contacts={contacts}
pipelineId={selectedPipeline.id}
/>
{/* Delete Confirmation Modal */}
<DeleteConfirmModal
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: '', name: '' })}
onConfirm={confirmDelete}
opportunityName={deleteConfirm.name}
/>
</div>
);
}