- 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>
1415 lines
48 KiB
TypeScript
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 "{opportunityName}"? 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>
|
|
);
|
|
}
|