## Backend (NestJS) - Entities: Lead, Opportunity, PipelineStage, Activity with TypeORM - Services: LeadsService, OpportunitiesService, PipelineService, ActivitiesService, SalesDashboardService - Controllers: LeadsController, OpportunitiesController, PipelineController, ActivitiesController, DashboardController - DTOs: Full set of Create/Update/Convert DTOs with validation - Tests: 5 test suites with comprehensive coverage ## Frontend (React) - Pages: /sales, /sales/leads, /sales/leads/[id], /sales/opportunities, /sales/opportunities/[id], /sales/activities - Components: SalesDashboard, ConversionFunnel, LeadsList, LeadForm, LeadCard, PipelineBoard, OpportunityCard, OpportunityForm, ActivityTimeline, ActivityForm - Hooks: useLeads, useOpportunities, usePipeline, useActivities, useSalesDashboard - Services: leads.api, opportunities.api, activities.api, pipeline.api, dashboard.api ## Documentation - Updated SAAS-018-sales.md with implementation details - Updated MASTER_INVENTORY.yml - status changed from specified to completed Story Points: 21 Sprint: 6 - Sales Foundation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
205 lines
7.5 KiB
TypeScript
205 lines
7.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { Plus, Calendar, Clock, AlertCircle } from 'lucide-react';
|
|
import { useActivities, useUpcomingActivities, useOverdueActivities, useActivityStats } from '../../../hooks/sales';
|
|
import { ActivityForm } from '../../../components/sales/ActivityForm';
|
|
import { ActivityType, ActivityStatus } from '../../../services/sales/activities.api';
|
|
|
|
export default function ActivitiesPage() {
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [typeFilter, setTypeFilter] = useState<ActivityType | ''>('');
|
|
const [statusFilter, setStatusFilter] = useState<ActivityStatus | ''>('');
|
|
const [page, setPage] = useState(1);
|
|
|
|
const { data: activities, isLoading } = useActivities({
|
|
type: typeFilter || undefined,
|
|
status: statusFilter || undefined,
|
|
page,
|
|
limit: 20,
|
|
});
|
|
|
|
const { data: upcoming } = useUpcomingActivities(7);
|
|
const { data: overdue } = useOverdueActivities();
|
|
const { data: stats } = useActivityStats();
|
|
|
|
const getTypeIcon = (type: ActivityType) => {
|
|
switch (type) {
|
|
case 'call':
|
|
return '📞';
|
|
case 'meeting':
|
|
return '👥';
|
|
case 'task':
|
|
return '✅';
|
|
case 'email':
|
|
return '📧';
|
|
case 'note':
|
|
return '📝';
|
|
default:
|
|
return '📋';
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: ActivityStatus) => {
|
|
switch (status) {
|
|
case 'pending':
|
|
return 'bg-yellow-100 text-yellow-800';
|
|
case 'completed':
|
|
return 'bg-green-100 text-green-800';
|
|
case 'cancelled':
|
|
return 'bg-gray-100 text-gray-800';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Activities</h1>
|
|
<p className="text-sm text-gray-500">
|
|
{stats?.total || 0} total activities
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowForm(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Activity
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="w-5 h-5 text-yellow-500" />
|
|
<div>
|
|
<p className="text-sm text-gray-500">Pending</p>
|
|
<p className="text-2xl font-bold">{stats?.pending || 0}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
|
<div>
|
|
<p className="text-sm text-gray-500">Overdue</p>
|
|
<p className="text-2xl font-bold text-red-600">{stats?.overdue || 0}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-5 h-5 text-blue-500" />
|
|
<div>
|
|
<p className="text-sm text-gray-500">Upcoming (7 days)</p>
|
|
<p className="text-2xl font-bold">{upcoming?.length || 0}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
|
<p className="text-sm text-gray-500">Completed</p>
|
|
<p className="text-2xl font-bold text-green-600">{stats?.completed || 0}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-4 bg-white p-4 rounded-lg shadow-sm border">
|
|
<select
|
|
value={typeFilter}
|
|
onChange={(e) => setTypeFilter(e.target.value as ActivityType | '')}
|
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="call">Calls</option>
|
|
<option value="meeting">Meetings</option>
|
|
<option value="task">Tasks</option>
|
|
<option value="email">Emails</option>
|
|
<option value="note">Notes</option>
|
|
</select>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as ActivityStatus | '')}
|
|
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">All Status</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Activities List */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-lg shadow-sm border divide-y">
|
|
{activities?.data.length === 0 ? (
|
|
<div className="p-8 text-center text-gray-500">
|
|
No activities found
|
|
</div>
|
|
) : (
|
|
activities?.data.map((activity) => (
|
|
<div key={activity.id} className="p-4 hover:bg-gray-50">
|
|
<div className="flex items-start gap-4">
|
|
<span className="text-2xl">{getTypeIcon(activity.type)}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium truncate">{activity.subject}</h3>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(activity.status)}`}>
|
|
{activity.status}
|
|
</span>
|
|
</div>
|
|
{activity.description && (
|
|
<p className="text-sm text-gray-500 line-clamp-1 mt-1">
|
|
{activity.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
|
{activity.due_date && (
|
|
<span>Due: {new Date(activity.due_date).toLocaleDateString()}</span>
|
|
)}
|
|
<span>Created: {new Date(activity.created_at).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{activities && activities.totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
className="px-4 py-2 border rounded-lg disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="text-sm text-gray-500">
|
|
Page {page} of {activities.totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(activities.totalPages, p + 1))}
|
|
disabled={page === activities.totalPages}
|
|
className="px-4 py-2 border rounded-lg disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity Form Modal */}
|
|
{showForm && (
|
|
<ActivityForm onClose={() => setShowForm(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|