[SAAS-018/020] feat: Add Sales and Commissions frontend modules

Sales Frontend (Sprint 2):
- Add services: leads, opportunities, activities, pipeline, dashboard APIs
- Add hooks: useSales with React Query integration
- Add pages: SalesPage, LeadsPage, LeadDetailPage, OpportunitiesPage, OpportunityDetailPage, ActivitiesPage
- Integrate routes in main router

Commissions Frontend (Sprint 4):
- Add services: schemes, entries, periods, assignments, dashboard APIs
- Add hooks: useCommissions with React Query integration
- Add pages: CommissionsPage, SchemesPage, EntriesPage, PeriodsPage, MyEarningsPage
- Integrate routes in main router

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-24 22:50:11 -06:00
parent f59bbfac64
commit 36ee5213c5
28 changed files with 4639 additions and 0 deletions

402
src/hooks/useCommissions.ts Normal file
View File

@ -0,0 +1,402 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
schemesApi,
assignmentsApi,
entriesApi,
periodsApi,
dashboardApi,
type SchemeFilters,
type CreateSchemeDto,
type UpdateSchemeDto,
type AssignmentFilters,
type CreateAssignmentDto,
type UpdateAssignmentDto,
type EntryFilters,
type CalculateCommissionDto,
type UpdateEntryStatusDto,
type PeriodFilters,
type CreatePeriodDto,
type MarkPeriodPaidDto,
} from '@/services/commissions';
// Schemes Hooks
export function useSchemes(filters?: SchemeFilters) {
return useQuery({
queryKey: ['commissions', 'schemes', filters],
queryFn: () => schemesApi.list(filters),
});
}
export function useActiveSchemes() {
return useQuery({
queryKey: ['commissions', 'schemes', 'active'],
queryFn: () => schemesApi.listActive(),
});
}
export function useScheme(id: string) {
return useQuery({
queryKey: ['commissions', 'schemes', id],
queryFn: () => schemesApi.get(id),
enabled: !!id,
});
}
export function useCreateScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSchemeDto) => schemesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes'] });
},
});
}
export function useUpdateScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateSchemeDto }) =>
schemesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes', id] });
},
});
}
export function useDeleteScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => schemesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes'] });
},
});
}
export function useDuplicateScheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, name }: { id: string; name?: string }) =>
schemesApi.duplicate(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes'] });
},
});
}
export function useToggleSchemeActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => schemesApi.toggleActive(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'schemes', id] });
},
});
}
// Assignments Hooks
export function useAssignments(filters?: AssignmentFilters) {
return useQuery({
queryKey: ['commissions', 'assignments', filters],
queryFn: () => assignmentsApi.list(filters),
});
}
export function useAssignment(id: string) {
return useQuery({
queryKey: ['commissions', 'assignments', id],
queryFn: () => assignmentsApi.get(id),
enabled: !!id,
});
}
export function useUserAssignments(userId: string) {
return useQuery({
queryKey: ['commissions', 'assignments', 'user', userId],
queryFn: () => assignmentsApi.getByUser(userId),
enabled: !!userId,
});
}
export function useUserActiveScheme(userId: string) {
return useQuery({
queryKey: ['commissions', 'assignments', 'user', userId, 'active'],
queryFn: () => assignmentsApi.getActiveScheme(userId),
enabled: !!userId,
});
}
export function useSchemeAssignees(schemeId: string) {
return useQuery({
queryKey: ['commissions', 'assignments', 'scheme', schemeId],
queryFn: () => assignmentsApi.getSchemeAssignees(schemeId),
enabled: !!schemeId,
});
}
export function useCreateAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAssignmentDto) => assignmentsApi.assign(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'assignments'] });
},
});
}
export function useUpdateAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateAssignmentDto }) =>
assignmentsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'assignments'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'assignments', id] });
},
});
}
export function useRemoveAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => assignmentsApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'assignments'] });
},
});
}
export function useDeactivateAssignment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => assignmentsApi.deactivate(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'assignments'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'assignments', id] });
},
});
}
// Entries Hooks
export function useEntries(filters?: EntryFilters) {
return useQuery({
queryKey: ['commissions', 'entries', filters],
queryFn: () => entriesApi.list(filters),
});
}
export function usePendingEntries(params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: ['commissions', 'entries', 'pending', params],
queryFn: () => entriesApi.listPending(params),
});
}
export function useEntry(id: string) {
return useQuery({
queryKey: ['commissions', 'entries', id],
queryFn: () => entriesApi.get(id),
enabled: !!id,
});
}
export function useUserEntries(userId: string, params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: ['commissions', 'entries', 'user', userId, params],
queryFn: () => entriesApi.getByUser(userId, params),
enabled: !!userId,
});
}
export function usePeriodEntries(periodId: string, params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: ['commissions', 'entries', 'period', periodId, params],
queryFn: () => entriesApi.getByPeriod(periodId, params),
enabled: !!periodId,
});
}
export function useCalculateCommission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CalculateCommissionDto) => entriesApi.calculate(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'entries'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'dashboard'] });
},
});
}
export function useSimulateCommission() {
return useMutation({
mutationFn: (data: CalculateCommissionDto) => entriesApi.simulate(data),
});
}
export function useUpdateEntryStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateEntryStatusDto }) =>
entriesApi.updateStatus(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'entries'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'entries', id] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'dashboard'] });
},
});
}
export function useBulkApproveEntries() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (entryIds: string[]) => entriesApi.bulkApprove(entryIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'entries'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'dashboard'] });
},
});
}
export function useBulkRejectEntries() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ entryIds, reason }: { entryIds: string[]; reason?: string }) =>
entriesApi.bulkReject(entryIds, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'entries'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'dashboard'] });
},
});
}
// Periods Hooks
export function usePeriods(filters?: PeriodFilters) {
return useQuery({
queryKey: ['commissions', 'periods', filters],
queryFn: () => periodsApi.list(filters),
});
}
export function useOpenPeriod() {
return useQuery({
queryKey: ['commissions', 'periods', 'open'],
queryFn: () => periodsApi.getOpen(),
});
}
export function usePeriod(id: string) {
return useQuery({
queryKey: ['commissions', 'periods', id],
queryFn: () => periodsApi.get(id),
enabled: !!id,
});
}
export function usePeriodSummary(id: string) {
return useQuery({
queryKey: ['commissions', 'periods', id, 'summary'],
queryFn: () => periodsApi.getSummary(id),
enabled: !!id,
});
}
export function useCreatePeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePeriodDto) => periodsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods'] });
},
});
}
export function useClosePeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => periodsApi.close(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods', id] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'dashboard'] });
},
});
}
export function useReopenPeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => periodsApi.reopen(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods', id] });
},
});
}
export function useMarkPeriodPaid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data?: MarkPeriodPaidDto }) =>
periodsApi.markAsPaid(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'periods', id] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'entries'] });
queryClient.invalidateQueries({ queryKey: ['commissions', 'dashboard'] });
},
});
}
// Dashboard Hooks
export function useCommissionsDashboard() {
return useQuery({
queryKey: ['commissions', 'dashboard'],
queryFn: () => dashboardApi.getSummary(),
});
}
export function useEarningsByUser(params?: { date_from?: string; date_to?: string }) {
return useQuery({
queryKey: ['commissions', 'dashboard', 'byUser', params],
queryFn: () => dashboardApi.getEarningsByUser(params),
});
}
export function useEarningsByPeriod() {
return useQuery({
queryKey: ['commissions', 'dashboard', 'byPeriod'],
queryFn: () => dashboardApi.getEarningsByPeriod(),
});
}
export function useTopEarners(params?: { limit?: number; date_from?: string; date_to?: string }) {
return useQuery({
queryKey: ['commissions', 'dashboard', 'topEarners', params],
queryFn: () => dashboardApi.getTopEarners(params),
});
}
export function useMyEarnings() {
return useQuery({
queryKey: ['commissions', 'dashboard', 'myEarnings'],
queryFn: () => dashboardApi.getMyEarnings(),
});
}
export function useUserEarnings(userId: string) {
return useQuery({
queryKey: ['commissions', 'dashboard', 'user', userId, 'earnings'],
queryFn: () => dashboardApi.getUserEarnings(userId),
enabled: !!userId,
});
}
export function useSchemePerformance(schemeId: string) {
return useQuery({
queryKey: ['commissions', 'dashboard', 'scheme', schemeId, 'performance'],
queryFn: () => dashboardApi.getSchemePerformance(schemeId),
enabled: !!schemeId,
});
}

398
src/hooks/useSales.ts Normal file
View File

@ -0,0 +1,398 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
leadsApi,
opportunitiesApi,
activitiesApi,
pipelineApi,
salesDashboardApi,
type LeadFilters,
type CreateLeadDto,
type UpdateLeadDto,
type ConvertLeadDto,
type OpportunityFilters,
type CreateOpportunityDto,
type UpdateOpportunityDto,
type MoveOpportunityDto,
type ActivityFilters,
type CreateActivityDto,
type UpdateActivityDto,
type CreatePipelineStageDto,
type UpdatePipelineStageDto,
} from '@/services/sales';
// Leads Hooks
export function useLeads(filters?: LeadFilters) {
return useQuery({
queryKey: ['sales', 'leads', filters],
queryFn: () => leadsApi.list(filters),
});
}
export function useLead(id: string) {
return useQuery({
queryKey: ['sales', 'leads', id],
queryFn: () => leadsApi.get(id),
enabled: !!id,
});
}
export function useLeadStats() {
return useQuery({
queryKey: ['sales', 'leads', 'stats'],
queryFn: () => leadsApi.getStats(),
});
}
export function useCreateLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateLeadDto) => leadsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'leads'] });
},
});
}
export function useUpdateLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateLeadDto }) =>
leadsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'leads'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'leads', id] });
},
});
}
export function useDeleteLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => leadsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'leads'] });
},
});
}
export function useConvertLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: ConvertLeadDto }) =>
leadsApi.convert(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'leads'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
},
});
}
export function useAssignLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
leadsApi.assign(id, userId),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'leads'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'leads', id] });
},
});
}
// Opportunities Hooks
export function useOpportunities(filters?: OpportunityFilters) {
return useQuery({
queryKey: ['sales', 'opportunities', filters],
queryFn: () => opportunitiesApi.list(filters),
});
}
export function useOpportunity(id: string) {
return useQuery({
queryKey: ['sales', 'opportunities', id],
queryFn: () => opportunitiesApi.get(id),
enabled: !!id,
});
}
export function usePipelineView() {
return useQuery({
queryKey: ['sales', 'opportunities', 'pipeline'],
queryFn: () => opportunitiesApi.getByStage(),
});
}
export function useOpportunityStats() {
return useQuery({
queryKey: ['sales', 'opportunities', 'stats'],
queryFn: () => opportunitiesApi.getStats(),
});
}
export function useCreateOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateOpportunityDto) => opportunitiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
},
});
}
export function useUpdateOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateOpportunityDto }) =>
opportunitiesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities', id] });
},
});
}
export function useMoveOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: MoveOpportunityDto }) =>
opportunitiesApi.move(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities', id] });
queryClient.invalidateQueries({ queryKey: ['sales', 'pipeline'] });
},
});
}
export function useDeleteOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => opportunitiesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
},
});
}
export function useMarkOpportunityWon() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
opportunitiesApi.markAsWon(id, notes),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities', id] });
queryClient.invalidateQueries({ queryKey: ['sales', 'dashboard'] });
},
});
}
export function useMarkOpportunityLost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
opportunitiesApi.markAsLost(id, reason),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'opportunities', id] });
queryClient.invalidateQueries({ queryKey: ['sales', 'dashboard'] });
},
});
}
// Activities Hooks
export function useActivities(filters?: ActivityFilters) {
return useQuery({
queryKey: ['sales', 'activities', filters],
queryFn: () => activitiesApi.list(filters),
});
}
export function useActivity(id: string) {
return useQuery({
queryKey: ['sales', 'activities', id],
queryFn: () => activitiesApi.get(id),
enabled: !!id,
});
}
export function useLeadActivities(leadId: string) {
return useQuery({
queryKey: ['sales', 'activities', 'lead', leadId],
queryFn: () => activitiesApi.getByLead(leadId),
enabled: !!leadId,
});
}
export function useOpportunityActivities(opportunityId: string) {
return useQuery({
queryKey: ['sales', 'activities', 'opportunity', opportunityId],
queryFn: () => activitiesApi.getByOpportunity(opportunityId),
enabled: !!opportunityId,
});
}
export function useUpcomingActivities(days?: number, userId?: string) {
return useQuery({
queryKey: ['sales', 'activities', 'upcoming', days, userId],
queryFn: () => activitiesApi.getUpcoming(days, userId),
});
}
export function useOverdueActivities(userId?: string) {
return useQuery({
queryKey: ['sales', 'activities', 'overdue', userId],
queryFn: () => activitiesApi.getOverdue(userId),
});
}
export function useActivityStats(userId?: string) {
return useQuery({
queryKey: ['sales', 'activities', 'stats', userId],
queryFn: () => activitiesApi.getStats(userId),
});
}
export function useCreateActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateActivityDto) => activitiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'activities'] });
},
});
}
export function useUpdateActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateActivityDto }) =>
activitiesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'activities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'activities', id] });
},
});
}
export function useDeleteActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => activitiesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'activities'] });
},
});
}
export function useCompleteActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, outcome }: { id: string; outcome?: string }) =>
activitiesApi.complete(id, outcome),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'activities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'activities', id] });
},
});
}
export function useCancelActivity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => activitiesApi.cancel(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['sales', 'activities'] });
queryClient.invalidateQueries({ queryKey: ['sales', 'activities', id] });
},
});
}
// Pipeline Hooks
export function usePipelineStages() {
return useQuery({
queryKey: ['sales', 'pipeline', 'stages'],
queryFn: () => pipelineApi.getStages(),
});
}
export function useCreatePipelineStage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePipelineStageDto) => pipelineApi.createStage(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'pipeline'] });
},
});
}
export function useUpdatePipelineStage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdatePipelineStageDto }) =>
pipelineApi.updateStage(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'pipeline'] });
},
});
}
export function useDeletePipelineStage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => pipelineApi.deleteStage(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'pipeline'] });
},
});
}
export function useReorderPipelineStages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (stageIds: string[]) => pipelineApi.reorderStages(stageIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'pipeline'] });
},
});
}
export function useInitializeDefaultStages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => pipelineApi.initializeDefaults(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sales', 'pipeline'] });
},
});
}
// Dashboard Hooks
export function useSalesDashboard() {
return useQuery({
queryKey: ['sales', 'dashboard'],
queryFn: () => salesDashboardApi.getSummary(),
});
}
export function useConversionRates(startDate?: string, endDate?: string) {
return useQuery({
queryKey: ['sales', 'dashboard', 'conversion', startDate, endDate],
queryFn: () => salesDashboardApi.getConversionRates(startDate, endDate),
});
}
export function useRevenueReport(startDate: string, endDate: string) {
return useQuery({
queryKey: ['sales', 'dashboard', 'revenue', startDate, endDate],
queryFn: () => salesDashboardApi.getRevenue(startDate, endDate),
enabled: !!startDate && !!endDate,
});
}
export function useTopSellers(limit?: number, startDate?: string, endDate?: string) {
return useQuery({
queryKey: ['sales', 'dashboard', 'topSellers', limit, startDate, endDate],
queryFn: () => salesDashboardApi.getTopSellers(limit, startDate, endDate),
});
}

View File

@ -0,0 +1,211 @@
import { useCommissionsDashboard, useTopEarners, useEarningsByPeriod } from '@/hooks/useCommissions';
export default function CommissionsPage() {
const { data: dashboard, isLoading } = useCommissionsDashboard();
const { data: topEarners } = useTopEarners({ limit: 5 });
const { data: earningsByPeriod } = useEarningsByPeriod();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Commissions Dashboard</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending Commissions</dt>
<dd className="text-lg font-semibold text-gray-900">
${(dashboard?.total_pending ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending Count</dt>
<dd className="text-lg font-semibold text-gray-900">
{dashboard?.pending_count ?? 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Approved</dt>
<dd className="text-lg font-semibold text-gray-900">
${(dashboard?.total_approved ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Paid Out</dt>
<dd className="text-lg font-semibold text-gray-900">
${(dashboard?.total_paid ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Top Earners */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Top Earners</h3>
<div className="space-y-3">
{topEarners?.map((earner, index) => (
<div key={earner.user_id} className="flex items-center justify-between">
<div className="flex items-center">
<span className="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-xs font-medium text-gray-600 mr-2">
{earner.rank || index + 1}
</span>
<span className="text-sm text-gray-600">{earner.user_name}</span>
</div>
<span className="text-sm font-medium text-green-600">${earner.total_earned.toLocaleString()}</span>
</div>
))}
{(!topEarners || topEarners.length === 0) && (
<p className="text-sm text-gray-500">No earnings data yet</p>
)}
</div>
</div>
{/* Earnings by Period */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Recent Periods</h3>
<div className="space-y-3">
{earningsByPeriod?.slice(0, 5).map((period) => (
<div key={period.period_id} className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-600">{period.period_name}</span>
<span className={`ml-2 px-2 py-0.5 text-xs rounded-full ${
period.status === 'paid' ? 'bg-green-100 text-green-800' :
period.status === 'closed' ? 'bg-yellow-100 text-yellow-800' :
'bg-blue-100 text-blue-800'
}`}>
{period.status}
</span>
</div>
<span className="text-sm font-medium text-gray-900">${period.total_amount.toLocaleString()}</span>
</div>
))}
{(!earningsByPeriod || earningsByPeriod.length === 0) && (
<p className="text-sm text-gray-500">No periods yet</p>
)}
</div>
</div>
</div>
{/* Quick Stats */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">System Status</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Active Schemes</span>
<span className="text-sm font-medium text-gray-900">{dashboard?.active_schemes ?? 0}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Active Assignments</span>
<span className="text-sm font-medium text-gray-900">{dashboard?.active_assignments ?? 0}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Open Periods</span>
<span className="text-sm font-medium text-gray-900">{dashboard?.open_periods ?? 0}</span>
</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<a
href="/dashboard/commissions/schemes"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Schemes</span>
</a>
<a
href="/dashboard/commissions/entries"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Entries</span>
</a>
<a
href="/dashboard/commissions/periods"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Periods</span>
</a>
<a
href="/dashboard/commissions/my-earnings"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Earnings</span>
</a>
<a
href="/dashboard/commissions/schemes/new"
className="flex items-center p-4 border border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100"
>
<span className="text-sm font-medium text-blue-900">+ New Scheme</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,307 @@
import { useState } from 'react';
import { useEntries, useUpdateEntryStatus, useBulkApproveEntries } from '@/hooks/useCommissions';
import type { EntryFilters, CommissionEntry, EntryStatus } from '@/services/commissions';
export default function EntriesPage() {
const [filters, setFilters] = useState<EntryFilters>({ page: 1, limit: 20 });
const [selectedEntries, setSelectedEntries] = useState<string[]>([]);
const [rejectModal, setRejectModal] = useState<{ id: string; notes: string } | null>(null);
const { data, isLoading, error } = useEntries(filters);
const updateStatus = useUpdateEntryStatus();
const bulkApprove = useBulkApproveEntries();
const handleApprove = async (id: string) => {
await updateStatus.mutateAsync({ id, data: { status: 'approved' } });
};
const handleReject = async () => {
if (!rejectModal) return;
await updateStatus.mutateAsync({
id: rejectModal.id,
data: { status: 'rejected', notes: rejectModal.notes || undefined }
});
setRejectModal(null);
};
const handleBulkApprove = async () => {
if (selectedEntries.length === 0) return;
await bulkApprove.mutateAsync(selectedEntries);
setSelectedEntries([]);
};
const toggleSelection = (id: string) => {
setSelectedEntries((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
};
const toggleSelectAll = () => {
const pendingEntries = data?.data?.filter((e: CommissionEntry) => e.status === 'pending') || [];
if (selectedEntries.length === pendingEntries.length) {
setSelectedEntries([]);
} else {
setSelectedEntries(pendingEntries.map((e: CommissionEntry) => e.id));
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading entries</p>
</div>
);
}
const getStatusColor = (status: EntryStatus) => {
const colors: Record<EntryStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
paid: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getUserName = (entry: CommissionEntry) => {
if (entry.user) {
return `${entry.user.first_name} ${entry.user.last_name}`;
}
return `User #${entry.user_id}`;
};
const getSchemeName = (entry: CommissionEntry) => {
return entry.scheme?.name || 'N/A';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Commission Entries</h1>
{selectedEntries.length > 0 && (
<button
onClick={handleBulkApprove}
disabled={bulkApprove.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
{bulkApprove.isPending ? 'Approving...' : `Approve Selected (${selectedEntries.length})`}
</button>
)}
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, status: (e.target.value || undefined) as EntryStatus | undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="paid">Paid</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">From Date</label>
<input
type="date"
value={filters.date_from || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, date_from: e.target.value || undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">To Date</label>
<input
type="date"
value={filters.date_to || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, date_to: e.target.value || undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Reference Type</label>
<input
type="text"
value={filters.reference_type || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, reference_type: e.target.value || undefined, page: 1 }))}
placeholder="e.g., opportunity"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Entries Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<input
type="checkbox"
checked={selectedEntries.length > 0 && selectedEntries.length === data?.data?.filter((e: CommissionEntry) => e.status === 'pending').length}
onChange={toggleSelectAll}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scheme</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Base Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Commission</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data?.data?.map((entry: CommissionEntry) => (
<tr key={entry.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
{entry.status === 'pending' && (
<input
type="checkbox"
checked={selectedEntries.includes(entry.id)}
onChange={() => toggleSelection(entry.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{getUserName(entry)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{getSchemeName(entry)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${entry.base_amount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-green-600">${entry.commission_amount.toLocaleString()}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(entry.status)}`}>
{entry.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(entry.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{entry.status === 'pending' && (
<>
<button
onClick={() => handleApprove(entry.id)}
disabled={updateStatus.isPending}
className="text-green-600 hover:text-green-900 mr-3"
>
Approve
</button>
<button
onClick={() => setRejectModal({ id: entry.id, notes: '' })}
className="text-red-600 hover:text-red-900"
>
Reject
</button>
</>
)}
{entry.status !== 'pending' && (
<a
href={`/dashboard/commissions/entries/${entry.id}`}
className="text-blue-600 hover:text-blue-900"
>
View
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{data?.data?.length === 0 && (
<div className="text-center py-10">
<p className="text-gray-500">No entries found</p>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing page <span className="font-medium">{filters.page || 1}</span> of{' '}
<span className="font-medium">{data.totalPages}</span> ({data.total} total)
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => setFilters((prev) => ({ ...prev, page: Math.max(1, (prev.page || 1) - 1) }))}
disabled={(filters.page || 1) <= 1}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setFilters((prev) => ({ ...prev, page: Math.min(data.totalPages, (prev.page || 1) + 1) }))}
disabled={(filters.page || 1) >= data.totalPages}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
)}
</div>
{/* Reject Modal */}
{rejectModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-lg font-medium text-gray-900 mb-4">Reject Entry</h2>
<div>
<label className="block text-sm font-medium text-gray-700">Reason</label>
<textarea
value={rejectModal.notes}
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
rows={3}
placeholder="Enter rejection reason..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setRejectModal(null)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleReject}
disabled={updateStatus.isPending || !rejectModal.notes}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
>
{updateStatus.isPending ? 'Rejecting...' : 'Reject'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,214 @@
import { useMyEarnings } from '@/hooks/useCommissions';
import type { CommissionEntry, EntryStatus } from '@/services/commissions';
export default function MyEarningsPage() {
const { data: earnings, isLoading } = useMyEarnings();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const getStatusColor = (status: EntryStatus) => {
const colors: Record<EntryStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
paid: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">My Earnings</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Earned</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earnings?.total ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earnings?.pending ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Approved</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earnings?.approved ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Paid</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earnings?.paid ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Additional Stats */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Summary</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Current Period</span>
<span className="text-sm font-medium text-gray-900">
${(earnings?.current_period_earnings ?? 0).toLocaleString()}
</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Total Entries</span>
<span className="text-sm font-medium text-gray-900">{earnings?.entries_count ?? 0}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Last Paid</span>
<span className="text-sm font-medium text-gray-900">
{earnings?.last_paid_date
? new Date(earnings.last_paid_date).toLocaleDateString()
: 'Never'}
</span>
</div>
</div>
</div>
{/* Recent Entries */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Recent Commission Entries</h2>
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reference</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Base Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Commission</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{earnings?.recent_entries?.map((entry: CommissionEntry) => (
<tr key={entry.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(entry.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{entry.reference_type}</div>
<div className="text-xs text-gray-500">#{entry.reference_id.substring(0, 8)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${entry.base_amount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-green-600">${entry.commission_amount.toLocaleString()}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(entry.status)}`}>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{(!earnings?.recent_entries || earnings.recent_entries.length === 0) && (
<div className="text-center py-10">
<p className="text-gray-500">No commission entries yet</p>
</div>
)}
</div>
{/* Quick Actions */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<a
href="/dashboard/commissions"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Dashboard</span>
</a>
<a
href="/dashboard/commissions/entries"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">All Entries</span>
</a>
<a
href="/dashboard/commissions/periods"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Payment Periods</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,261 @@
import { useState } from 'react';
import { usePeriods, useClosePeriod, useMarkPeriodPaid } from '@/hooks/useCommissions';
import type { PeriodFilters, CommissionPeriod, PeriodStatus } from '@/services/commissions';
export default function PeriodsPage() {
const [filters, setFilters] = useState<PeriodFilters>({ page: 1, limit: 20 });
const [payModal, setPayModal] = useState<{ id: string; reference: string; notes: string } | null>(null);
const { data, isLoading, error } = usePeriods(filters);
const closePeriod = useClosePeriod();
const markPaid = useMarkPeriodPaid();
const handleClose = async (id: string) => {
if (window.confirm('Are you sure you want to close this period? This action cannot be undone.')) {
await closePeriod.mutateAsync(id);
}
};
const handleMarkPaid = async () => {
if (!payModal) return;
await markPaid.mutateAsync({
id: payModal.id,
data: {
payment_reference: payModal.reference || undefined,
payment_notes: payModal.notes || undefined,
}
});
setPayModal(null);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading periods</p>
</div>
);
}
const getStatusColor = (status: PeriodStatus) => {
const colors: Record<PeriodStatus, string> = {
open: 'bg-blue-100 text-blue-800',
closed: 'bg-yellow-100 text-yellow-800',
processing: 'bg-orange-100 text-orange-800',
paid: 'bg-green-100 text-green-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Commission Periods</h1>
<a
href="/dashboard/commissions/periods/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ New Period
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, status: (e.target.value || undefined) as PeriodStatus | undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="processing">Processing</option>
<option value="paid">Paid</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">From Date</label>
<input
type="date"
value={filters.date_from || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, date_from: e.target.value || undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">To Date</label>
<input
type="date"
value={filters.date_to || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, date_to: e.target.value || undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Periods List */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Period</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date Range</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entries</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data?.data?.map((period: CommissionPeriod) => (
<tr key={period.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{period.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(period.starts_at).toLocaleDateString()} - {new Date(period.ends_at).toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{period.total_entries || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-green-600">
${(period.total_amount || 0).toLocaleString()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(period.status)}`}>
{period.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{period.status === 'open' && (
<button
onClick={() => handleClose(period.id)}
disabled={closePeriod.isPending}
className="text-yellow-600 hover:text-yellow-900"
>
Close
</button>
)}
{period.status === 'closed' && (
<button
onClick={() => setPayModal({ id: period.id, reference: '', notes: '' })}
className="text-green-600 hover:text-green-900"
>
Mark Paid
</button>
)}
{(period.status === 'paid' || period.status === 'processing') && (
<a
href={`/dashboard/commissions/periods/${period.id}`}
className="text-blue-600 hover:text-blue-900"
>
View
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{data?.data?.length === 0 && (
<div className="text-center py-10">
<p className="text-gray-500">No periods found</p>
<a href="/dashboard/commissions/periods/new" className="text-blue-600 hover:text-blue-900">
Create your first period
</a>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing page <span className="font-medium">{filters.page || 1}</span> of{' '}
<span className="font-medium">{data.totalPages}</span> ({data.total} total)
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => setFilters((prev) => ({ ...prev, page: Math.max(1, (prev.page || 1) - 1) }))}
disabled={(filters.page || 1) <= 1}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setFilters((prev) => ({ ...prev, page: Math.min(data.totalPages, (prev.page || 1) + 1) }))}
disabled={(filters.page || 1) >= data.totalPages}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
)}
</div>
{/* Pay Modal */}
{payModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-lg font-medium text-gray-900 mb-4">Mark Period as Paid</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Payment Reference</label>
<input
type="text"
value={payModal.reference}
onChange={(e) => setPayModal({ ...payModal, reference: e.target.value })}
placeholder="e.g., Wire transfer #12345"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Notes (optional)</label>
<textarea
value={payModal.notes}
onChange={(e) => setPayModal({ ...payModal, notes: e.target.value })}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setPayModal(null)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleMarkPaid}
disabled={markPaid.isPending}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
{markPaid.isPending ? 'Processing...' : 'Mark as Paid'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,187 @@
import { useState } from 'react';
import { useSchemes, useDeleteScheme, useToggleSchemeActive } from '@/hooks/useCommissions';
import type { SchemeFilters, CommissionScheme } from '@/services/commissions';
export default function SchemesPage() {
const [filters, setFilters] = useState<SchemeFilters>({ page: 1, limit: 20 });
const { data, isLoading, error } = useSchemes(filters);
const deleteScheme = useDeleteScheme();
const toggleActive = useToggleSchemeActive();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this scheme?')) {
await deleteScheme.mutateAsync(id);
}
};
const handleToggleActive = async (id: string) => {
await toggleActive.mutateAsync(id);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading schemes</p>
</div>
);
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
percentage: 'Percentage',
fixed: 'Fixed Amount',
tiered: 'Tiered',
};
return labels[type] || type;
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Commission Schemes</h1>
<a
href="/dashboard/commissions/schemes/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Add Scheme
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={filters.type || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, type: (e.target.value || undefined) as SchemeFilters['type'], page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Types</option>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</option>
<option value="tiered">Tiered</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.is_active === undefined ? '' : filters.is_active ? 'true' : 'false'}
onChange={(e) => setFilters((prev) => ({
...prev,
is_active: e.target.value === '' ? undefined : e.target.value === 'true',
page: 1
}))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
value={filters.search || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, search: e.target.value || undefined, page: 1 }))}
placeholder="Search by name..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Schemes Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data?.data?.map((scheme: CommissionScheme) => (
<div
key={scheme.id}
className="bg-white shadow rounded-lg p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-medium text-gray-900">{scheme.name}</h3>
<p className="text-sm text-gray-500">{getTypeLabel(scheme.type)}</p>
</div>
<button
onClick={() => handleToggleActive(scheme.id)}
disabled={toggleActive.isPending}
className={`px-2 py-1 text-xs font-semibold rounded-full ${
scheme.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{scheme.is_active ? 'Active' : 'Inactive'}
</button>
</div>
{scheme.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{scheme.description}</p>
)}
<div className="space-y-2 mb-4">
{scheme.type === 'percentage' && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Rate</span>
<span className="font-medium">{scheme.rate}%</span>
</div>
)}
{scheme.type === 'fixed' && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Amount</span>
<span className="font-medium">${scheme.fixed_amount?.toLocaleString()}</span>
</div>
)}
{scheme.min_amount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Min Amount</span>
<span className="font-medium">${scheme.min_amount.toLocaleString()}</span>
</div>
)}
{scheme.max_amount && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Max Amount</span>
<span className="font-medium">${scheme.max_amount.toLocaleString()}</span>
</div>
)}
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<a
href={`/dashboard/commissions/schemes/${scheme.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View Details
</a>
<button
onClick={() => handleDelete(scheme.id)}
className="text-sm text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</div>
))}
</div>
{data?.data?.length === 0 && (
<div className="text-center py-10 bg-white rounded-lg shadow">
<p className="text-gray-500">No schemes found</p>
<a href="/dashboard/commissions/schemes/new" className="text-blue-600 hover:text-blue-900">
Create your first scheme
</a>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,5 @@
export { default as CommissionsPage } from './CommissionsPage';
export { default as SchemesPage } from './SchemesPage';
export { default as EntriesPage } from './EntriesPage';
export { default as PeriodsPage } from './PeriodsPage';
export { default as MyEarningsPage } from './MyEarningsPage';

View File

@ -0,0 +1,188 @@
import { useState } from 'react';
import { useActivities, useDeleteActivity } from '@/hooks/useSales';
import type { ActivityFilters, Activity, ActivityType, ActivityStatus } from '@/services/sales';
export default function ActivitiesPage() {
const [filters, setFilters] = useState<ActivityFilters>({ page: 1, limit: 20 });
const { data, isLoading, error } = useActivities(filters);
const deleteActivity = useDeleteActivity();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this activity?')) {
await deleteActivity.mutateAsync(id);
}
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading activities</p>
</div>
);
}
const getTypeIcon = (type: ActivityType) => {
const icons: Record<ActivityType, string> = {
call: '📞',
email: '📧',
meeting: '📅',
task: '✅',
note: '📝',
};
return icons[type] || '📋';
};
const getStatusColor = (status: ActivityStatus) => {
const colors: Record<ActivityStatus, string> = {
pending: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Activities</h1>
<a
href="/dashboard/sales/activities/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Add Activity
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={filters.type || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, type: (e.target.value || undefined) as ActivityType | undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="call">Call</option>
<option value="email">Email</option>
<option value="meeting">Meeting</option>
<option value="task">Task</option>
<option value="note">Note</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, status: (e.target.value || undefined) as ActivityStatus | undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
{/* Activities List */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<ul className="divide-y divide-gray-200">
{data?.data?.map((activity: Activity) => (
<li key={activity.id} className="p-4 hover:bg-gray-50">
<div className="flex items-start space-x-4">
<div className="text-2xl">{getTypeIcon(activity.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate">{activity.subject}</p>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(activity.status)}`}>
{activity.status}
</span>
</div>
{activity.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{activity.description}</p>
)}
<div className="flex items-center mt-2 space-x-4 text-xs text-gray-500">
<span className="capitalize">{activity.type}</span>
{activity.due_date && (
<span>Due: {new Date(activity.due_date).toLocaleDateString()}</span>
)}
{activity.lead_id && <span>Lead Activity</span>}
{activity.opportunity_id && <span>Opportunity Activity</span>}
</div>
</div>
<div className="flex items-center space-x-2">
<a
href={`/dashboard/sales/activities/${activity.id}`}
className="text-blue-600 hover:text-blue-900 text-sm"
>
View
</a>
<button
onClick={() => handleDelete(activity.id)}
className="text-red-600 hover:text-red-900 text-sm"
>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
{data?.data?.length === 0 && (
<div className="text-center py-10">
<p className="text-gray-500">No activities found</p>
<a href="/dashboard/sales/activities/new" className="text-blue-600 hover:text-blue-900">
Create your first activity
</a>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing page <span className="font-medium">{filters.page || 1}</span> of{' '}
<span className="font-medium">{data.totalPages}</span> ({data.total} total)
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => handlePageChange(Math.max(1, (filters.page || 1) - 1))}
disabled={(filters.page || 1) <= 1}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => handlePageChange(Math.min(data.totalPages, (filters.page || 1) + 1))}
disabled={(filters.page || 1) >= data.totalPages}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,376 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useLead, useUpdateLead, useConvertLead, useActivities, useCreateActivity } from '@/hooks/useSales';
import type { UpdateLeadDto, CreateActivityDto } from '@/services/sales';
export default function LeadDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: lead, isLoading, error } = useLead(id || '');
const { data: activities } = useActivities({ lead_id: id, limit: 10 });
const updateLead = useUpdateLead();
const convertLead = useConvertLead();
const createActivity = useCreateActivity();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<UpdateLeadDto>({});
const [showConvertModal, setShowConvertModal] = useState(false);
const [convertData, setConvertData] = useState({ opportunity_name: '', amount: 0, expected_close_date: '' });
const [showActivityModal, setShowActivityModal] = useState(false);
const [activityData, setActivityData] = useState<CreateActivityDto>({
lead_id: id || '',
type: 'note',
subject: '',
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !lead) {
return (
<div className="text-center py-10">
<p className="text-red-600">Lead not found</p>
<a href="/dashboard/sales/leads" className="text-blue-600 hover:text-blue-900">
Back to leads
</a>
</div>
);
}
const handleUpdate = async () => {
if (!id) return;
await updateLead.mutateAsync({ id, data: formData });
setIsEditing(false);
};
const handleConvert = async () => {
if (!id) return;
const result = await convertLead.mutateAsync({
id,
data: {
opportunity_name: convertData.opportunity_name || `${lead.first_name} ${lead.last_name} - Opportunity`,
amount: convertData.amount,
expected_close_date: convertData.expected_close_date || undefined,
},
});
navigate(`/dashboard/sales/opportunities/${result.opportunity_id}`);
};
const handleCreateActivity = async () => {
await createActivity.mutateAsync(activityData);
setShowActivityModal(false);
setActivityData({ lead_id: id || '', type: 'note', subject: '' });
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
new: 'bg-blue-100 text-blue-800',
contacted: 'bg-yellow-100 text-yellow-800',
qualified: 'bg-green-100 text-green-800',
unqualified: 'bg-red-100 text-red-800',
converted: 'bg-purple-100 text-purple-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<a href="/dashboard/sales/leads" className="text-sm text-blue-600 hover:text-blue-900">
Back to leads
</a>
<h1 className="text-2xl font-semibold text-gray-900 mt-2">
{lead.first_name} {lead.last_name}
</h1>
</div>
<div className="flex space-x-3">
{lead.status !== 'converted' && (
<button
onClick={() => setShowConvertModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
Convert to Opportunity
</button>
)}
<button
onClick={() => setIsEditing(!isEditing)}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
{isEditing ? 'Cancel' : 'Edit'}
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Lead Info */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Lead Information</h2>
<span className={`px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(lead.status)}`}>
{lead.status}
</span>
</div>
{isEditing ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
defaultValue={lead.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
defaultValue={lead.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
defaultValue={lead.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
defaultValue={lead.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'new' | 'contacted' | 'qualified' | 'unqualified' | 'converted' })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="new">New</option>
<option value="contacted">Contacted</option>
<option value="qualified">Qualified</option>
<option value="unqualified">Unqualified</option>
</select>
</div>
<button
onClick={handleUpdate}
disabled={updateLead.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{updateLead.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
) : (
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="mt-1 text-sm text-gray-900">{lead.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Phone</dt>
<dd className="mt-1 text-sm text-gray-900">{lead.phone || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Company</dt>
<dd className="mt-1 text-sm text-gray-900">{lead.company || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Job Title</dt>
<dd className="mt-1 text-sm text-gray-900">{lead.job_title || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source</dt>
<dd className="mt-1 text-sm text-gray-900 capitalize">{lead.source.replace('_', ' ')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Score</dt>
<dd className="mt-1 text-sm text-gray-900">{lead.score}</dd>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-gray-500">Notes</dt>
<dd className="mt-1 text-sm text-gray-900">{lead.notes || 'No notes'}</dd>
</div>
</dl>
)}
</div>
{/* Activities */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Recent Activities</h2>
<button
onClick={() => setShowActivityModal(true)}
className="text-sm text-blue-600 hover:text-blue-900"
>
+ Add Activity
</button>
</div>
{activities?.data?.length ? (
<ul className="space-y-3">
{activities.data.map((activity) => (
<li key={activity.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{activity.subject}</p>
<p className="text-xs text-gray-500 capitalize">{activity.type.replace('_', ' ')}</p>
</div>
<span className="text-xs text-gray-500">
{new Date(activity.created_at).toLocaleDateString()}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">No activities yet</p>
)}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Details</h3>
<dl className="space-y-3">
<div>
<dt className="text-xs font-medium text-gray-500">Created</dt>
<dd className="text-sm text-gray-900">{new Date(lead.created_at).toLocaleDateString()}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500">Last Updated</dt>
<dd className="text-sm text-gray-900">{new Date(lead.updated_at).toLocaleDateString()}</dd>
</div>
{lead.assigned_to && (
<div>
<dt className="text-xs font-medium text-gray-500">Assigned To</dt>
<dd className="text-sm text-gray-900">User #{lead.assigned_to}</dd>
</div>
)}
</dl>
</div>
</div>
</div>
{/* Convert Modal */}
{showConvertModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-lg font-medium text-gray-900 mb-4">Convert to Opportunity</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Opportunity Name</label>
<input
type="text"
value={convertData.opportunity_name}
onChange={(e) => setConvertData({ ...convertData, opportunity_name: e.target.value })}
placeholder={`${lead.first_name} ${lead.last_name} - Opportunity`}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Amount</label>
<input
type="number"
value={convertData.amount}
onChange={(e) => setConvertData({ ...convertData, amount: Number(e.target.value) })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Expected Close Date</label>
<input
type="date"
value={convertData.expected_close_date}
onChange={(e) => setConvertData({ ...convertData, expected_close_date: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setShowConvertModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleConvert}
disabled={convertLead.isPending}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
{convertLead.isPending ? 'Converting...' : 'Convert'}
</button>
</div>
</div>
</div>
)}
{/* Activity Modal */}
{showActivityModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-lg font-medium text-gray-900 mb-4">Add Activity</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={activityData.type}
onChange={(e) => setActivityData({ ...activityData, type: e.target.value as CreateActivityDto['type'] })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="call">Call</option>
<option value="meeting">Meeting</option>
<option value="task">Task</option>
<option value="email">Email</option>
<option value="note">Note</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subject</label>
<input
type="text"
value={activityData.subject}
onChange={(e) => setActivityData({ ...activityData, subject: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
value={activityData.description || ''}
onChange={(e) => setActivityData({ ...activityData, description: e.target.value })}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setShowActivityModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleCreateActivity}
disabled={createActivity.isPending || !activityData.subject}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{createActivity.isPending ? 'Adding...' : 'Add Activity'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,184 @@
import { useState } from 'react';
import { useLeads, useDeleteLead } from '@/hooks/useSales';
import type { LeadFilters, LeadStatus, LeadSource, Lead } from '@/services/sales';
export default function LeadsPage() {
const [filters, setFilters] = useState<LeadFilters>({ page: 1, limit: 20 });
const { data, isLoading, error } = useLeads(filters);
const deleteLead = useDeleteLead();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this lead?')) {
await deleteLead.mutateAsync(id);
}
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading leads</p>
</div>
);
}
const getStatusColor = (status: LeadStatus) => {
const colors: Record<LeadStatus, string> = {
new: 'bg-blue-100 text-blue-800',
contacted: 'bg-yellow-100 text-yellow-800',
qualified: 'bg-green-100 text-green-800',
unqualified: 'bg-red-100 text-red-800',
converted: 'bg-purple-100 text-purple-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Leads</h1>
<a
href="/dashboard/sales/leads/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Add Lead
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, status: (e.target.value || undefined) as LeadStatus | undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="new">New</option>
<option value="contacted">Contacted</option>
<option value="qualified">Qualified</option>
<option value="unqualified">Unqualified</option>
<option value="converted">Converted</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Source</label>
<select
value={filters.source || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, source: (e.target.value || undefined) as LeadSource | undefined, page: 1 }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="website">Website</option>
<option value="referral">Referral</option>
<option value="cold_call">Cold Call</option>
<option value="event">Event</option>
<option value="advertisement">Advertisement</option>
<option value="social_media">Social Media</option>
<option value="other">Other</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
value={filters.search || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, search: e.target.value || undefined, page: 1 }))}
placeholder="Search by name, email, company..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Leads Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Score</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data?.data?.map((lead: Lead) => (
<tr key={lead.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{lead.first_name} {lead.last_name}
</div>
<div className="text-sm text-gray-500">{lead.email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{lead.company || '-'}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(lead.status)}`}>
{lead.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{lead.source.replace('_', ' ')}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{lead.score}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href={`/dashboard/sales/leads/${lead.id}`} className="text-blue-600 hover:text-blue-900 mr-3">
View
</a>
<button onClick={() => handleDelete(lead.id)} className="text-red-600 hover:text-red-900">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing page <span className="font-medium">{filters.page || 1}</span> of{' '}
<span className="font-medium">{data.totalPages}</span> ({data.total} total)
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => handlePageChange(Math.max(1, (filters.page || 1) - 1))}
disabled={(filters.page || 1) <= 1}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => handlePageChange(Math.min(data.totalPages, (filters.page || 1) + 1))}
disabled={(filters.page || 1) >= data.totalPages}
className="px-3 py-1 border rounded text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,105 @@
import { useState } from 'react';
import { useOpportunities, usePipelineStages } from '@/hooks/useSales';
import type { OpportunityFilters, Opportunity, OpportunityStage } from '@/services/sales';
export default function OpportunitiesPage() {
const [filters, setFilters] = useState<OpportunityFilters>({ page: 1, limit: 20 });
const { data: opportunities, isLoading } = useOpportunities(filters);
const { data: stages } = usePipelineStages();
const getStageColor = (stage: OpportunityStage) => {
const colors: Record<OpportunityStage, string> = {
prospecting: 'bg-gray-100 text-gray-800',
qualification: 'bg-blue-100 text-blue-800',
proposal: 'bg-purple-100 text-purple-800',
negotiation: 'bg-yellow-100 text-yellow-800',
closed_won: 'bg-green-100 text-green-800',
closed_lost: 'bg-red-100 text-red-800',
};
return colors[stage] || 'bg-gray-100 text-gray-800';
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Opportunities</h1>
<a
href="/dashboard/sales/opportunities/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Add Opportunity
</a>
</div>
{/* Stage Filters */}
<div className="flex space-x-2 overflow-x-auto pb-2">
<button
onClick={() => setFilters((prev) => ({ ...prev, stage: undefined, stage_id: undefined, page: 1 }))}
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap ${
!filters.stage && !filters.stage_id ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</button>
{stages?.map((stage) => (
<button
key={stage.id}
onClick={() => setFilters((prev) => ({ ...prev, stage_id: stage.id, stage: undefined, page: 1 }))}
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap ${
filters.stage_id === stage.id ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{stage.name} ({stage.opportunityCount || 0})
</button>
))}
</div>
{/* Opportunities Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{opportunities?.data?.map((opp: Opportunity) => (
<div
key={opp.id}
className="bg-white shadow rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => window.location.href = `/dashboard/sales/opportunities/${opp.id}`}
>
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-900 truncate">{opp.name}</h3>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getStageColor(opp.stage)}`}>
{opp.stage.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-500 mb-2">{opp.company_name || 'No company'}</p>
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">
${opp.amount.toLocaleString()}
</span>
<span className="text-sm text-gray-500">{opp.probability}% probability</span>
</div>
{opp.expected_close_date && (
<p className="text-xs text-gray-500 mt-2">
Expected close: {new Date(opp.expected_close_date).toLocaleDateString()}
</p>
)}
</div>
))}
</div>
{opportunities?.data?.length === 0 && (
<div className="text-center py-10">
<p className="text-gray-500">No opportunities found</p>
<a href="/dashboard/sales/opportunities/new" className="text-blue-600 hover:text-blue-900">
Create your first opportunity
</a>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,379 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useOpportunity, useUpdateOpportunity, usePipelineStages, useActivities, useCreateActivity } from '@/hooks/useSales';
import type { UpdateOpportunityDto, CreateActivityDto } from '@/services/sales';
export default function OpportunityDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: opportunity, isLoading, error } = useOpportunity(id || '');
const { data: stages } = usePipelineStages();
const { data: activities } = useActivities({ opportunity_id: id, limit: 10 });
const updateOpportunity = useUpdateOpportunity();
const createActivity = useCreateActivity();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<UpdateOpportunityDto>({});
const [showActivityModal, setShowActivityModal] = useState(false);
const [activityData, setActivityData] = useState<CreateActivityDto>({
opportunity_id: id || '',
type: 'note',
subject: '',
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !opportunity) {
return (
<div className="text-center py-10">
<p className="text-red-600">Opportunity not found</p>
<a href="/dashboard/sales/opportunities" className="text-blue-600 hover:text-blue-900">
Back to opportunities
</a>
</div>
);
}
const handleUpdate = async () => {
if (!id) return;
await updateOpportunity.mutateAsync({ id, data: formData });
setIsEditing(false);
};
const handleStageChange = async (stageId: string) => {
if (!id) return;
await updateOpportunity.mutateAsync({ id, data: { stage_id: stageId } });
};
const handleCreateActivity = async () => {
await createActivity.mutateAsync(activityData);
setShowActivityModal(false);
setActivityData({ opportunity_id: id || '', type: 'note', subject: '' });
};
const getStageColor = (stage: string) => {
const colors: Record<string, string> = {
prospecting: 'bg-gray-100 text-gray-800',
qualification: 'bg-blue-100 text-blue-800',
proposal: 'bg-purple-100 text-purple-800',
negotiation: 'bg-yellow-100 text-yellow-800',
closed_won: 'bg-green-100 text-green-800',
closed_lost: 'bg-red-100 text-red-800',
};
return colors[stage] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<a href="/dashboard/sales/opportunities" className="text-sm text-blue-600 hover:text-blue-900">
Back to opportunities
</a>
<h1 className="text-2xl font-semibold text-gray-900 mt-2">{opportunity.name}</h1>
</div>
<div className="flex space-x-3">
<button
onClick={() => setIsEditing(!isEditing)}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
{isEditing ? 'Cancel' : 'Edit'}
</button>
</div>
</div>
{/* Stage Pipeline */}
<div className="bg-white shadow rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Stage</h3>
<div className="flex space-x-2 overflow-x-auto pb-2">
{stages?.map((stage) => (
<button
key={stage.id}
onClick={() => handleStageChange(stage.id)}
disabled={updateOpportunity.isPending}
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
opportunity.stage_id === stage.id
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{stage.name}
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Opportunity Info */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Opportunity Information</h2>
<span className={`px-3 py-1 text-sm font-semibold rounded-full ${getStageColor(opportunity.stage)}`}>
{opportunity.stage.replace('_', ' ')}
</span>
</div>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
defaultValue={opportunity.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Amount</label>
<input
type="number"
defaultValue={opportunity.amount}
onChange={(e) => setFormData({ ...formData, amount: Number(e.target.value) })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Probability (%)</label>
<input
type="number"
min="0"
max="100"
defaultValue={opportunity.probability}
onChange={(e) => setFormData({ ...formData, probability: Number(e.target.value) })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Expected Close Date</label>
<input
type="date"
defaultValue={opportunity.expected_close_date?.split('T')[0]}
onChange={(e) => setFormData({ ...formData, expected_close_date: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
defaultValue={opportunity.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<button
onClick={handleUpdate}
disabled={updateOpportunity.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{updateOpportunity.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
) : (
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Amount</dt>
<dd className="mt-1 text-2xl font-semibold text-gray-900">${opportunity.amount.toLocaleString()}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Probability</dt>
<dd className="mt-1 text-2xl font-semibold text-gray-900">{opportunity.probability}%</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Weighted Value</dt>
<dd className="mt-1 text-lg text-gray-900">
${((opportunity.amount * opportunity.probability) / 100).toLocaleString()}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Expected Close</dt>
<dd className="mt-1 text-lg text-gray-900">
{opportunity.expected_close_date
? new Date(opportunity.expected_close_date).toLocaleDateString()
: 'Not set'}
</dd>
</div>
{opportunity.company_name && (
<div>
<dt className="text-sm font-medium text-gray-500">Company</dt>
<dd className="mt-1 text-sm text-gray-900">{opportunity.company_name}</dd>
</div>
)}
{opportunity.contact_name && (
<div>
<dt className="text-sm font-medium text-gray-500">Contact</dt>
<dd className="mt-1 text-sm text-gray-900">{opportunity.contact_name}</dd>
</div>
)}
{opportunity.description && (
<div className="col-span-2">
<dt className="text-sm font-medium text-gray-500">Description</dt>
<dd className="mt-1 text-sm text-gray-900">{opportunity.description}</dd>
</div>
)}
</dl>
)}
</div>
{/* Activities */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Recent Activities</h2>
<button
onClick={() => setShowActivityModal(true)}
className="text-sm text-blue-600 hover:text-blue-900"
>
+ Add Activity
</button>
</div>
{activities?.data?.length ? (
<ul className="space-y-3">
{activities.data.map((activity) => (
<li key={activity.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{activity.subject}</p>
<p className="text-xs text-gray-500 capitalize">{activity.type.replace('_', ' ')}</p>
</div>
<span className="text-xs text-gray-500">
{new Date(activity.created_at).toLocaleDateString()}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">No activities yet</p>
)}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Details</h3>
<dl className="space-y-3">
<div>
<dt className="text-xs font-medium text-gray-500">Created</dt>
<dd className="text-sm text-gray-900">{new Date(opportunity.created_at).toLocaleDateString()}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500">Last Updated</dt>
<dd className="text-sm text-gray-900">{new Date(opportunity.updated_at).toLocaleDateString()}</dd>
</div>
{opportunity.actual_close_date && (
<div>
<dt className="text-xs font-medium text-gray-500">Closed At</dt>
<dd className="text-sm text-gray-900">{new Date(opportunity.actual_close_date).toLocaleDateString()}</dd>
</div>
)}
{opportunity.assigned_to && (
<div>
<dt className="text-xs font-medium text-gray-500">Assigned To</dt>
<dd className="text-sm text-gray-900">User #{opportunity.assigned_to}</dd>
</div>
)}
{opportunity.lead_id && (
<div>
<dt className="text-xs font-medium text-gray-500">Source Lead</dt>
<dd className="text-sm">
<a href={`/dashboard/sales/leads/${opportunity.lead_id}`} className="text-blue-600 hover:text-blue-900">
View Lead
</a>
</dd>
</div>
)}
</dl>
</div>
{/* Quick Stats */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Summary</h3>
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Deal Value</span>
<span className="text-sm font-medium">${opportunity.amount.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Win Probability</span>
<span className="text-sm font-medium">{opportunity.probability}%</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Expected Value</span>
<span className="text-sm font-medium text-green-600">
${((opportunity.amount * opportunity.probability) / 100).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Activity Modal */}
{showActivityModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-lg font-medium text-gray-900 mb-4">Add Activity</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={activityData.type}
onChange={(e) => setActivityData({ ...activityData, type: e.target.value as CreateActivityDto['type'] })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="call">Call</option>
<option value="meeting">Meeting</option>
<option value="task">Task</option>
<option value="email">Email</option>
<option value="note">Note</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subject</label>
<input
type="text"
value={activityData.subject}
onChange={(e) => setActivityData({ ...activityData, subject: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
value={activityData.description || ''}
onChange={(e) => setActivityData({ ...activityData, description: e.target.value })}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setShowActivityModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleCreateActivity}
disabled={createActivity.isPending || !activityData.subject}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{createActivity.isPending ? 'Adding...' : 'Add Activity'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,163 @@
import { useSalesDashboard, usePipelineView, useLeadStats } from '@/hooks/useSales';
export default function SalesPage() {
const { data: dashboard, isLoading } = useSalesDashboard();
const { data: pipelineView } = usePipelineView();
const { data: leadStats } = useLeadStats();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Sales Dashboard</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Leads</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.leads?.total ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Opportunities</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.opportunities?.total ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pipeline Value</dt>
<dd className="text-lg font-semibold text-gray-900">
${(dashboard?.pipeline?.totalValue ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Won Deals</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.opportunities?.won ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Leads by Status */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Leads by Status</h3>
<div className="space-y-3">
{leadStats && Object.entries(leadStats.byStatus).map(([status, count]) => (
<div key={status} className="flex items-center justify-between">
<span className="text-sm text-gray-600 capitalize">{status.replace('_', ' ')}</span>
<span className="text-sm font-medium text-gray-900">{count}</span>
</div>
))}
</div>
</div>
{/* Opportunities by Stage */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Opportunities by Stage</h3>
<div className="space-y-3">
{pipelineView?.map((stage) => (
<div key={stage.stage} className="flex items-center justify-between">
<span className="text-sm text-gray-600 capitalize">{stage.stageName}</span>
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900 mr-2">{stage.count}</span>
<span className="text-xs text-gray-500">(${stage.totalAmount.toLocaleString()})</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<a
href="/dashboard/sales/leads"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">View Leads</span>
</a>
<a
href="/dashboard/sales/opportunities"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">View Pipeline</span>
</a>
<a
href="/dashboard/sales/activities"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Activities</span>
</a>
<a
href="/dashboard/sales/leads/new"
className="flex items-center p-4 border border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100"
>
<span className="text-sm font-medium text-blue-900">+ New Lead</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
export { default as SalesPage } from './SalesPage';
export { default as LeadsPage } from './LeadsPage';
export { default as LeadDetailPage } from './LeadDetailPage';
export { default as OpportunitiesPage } from './OpportunitiesPage';
export { default as OpportunityDetailPage } from './OpportunityDetailPage';
export { default as ActivitiesPage } from './ActivitiesPage';

View File

@ -31,6 +31,21 @@ const WebhooksPage = lazy(() => import('@/pages/dashboard/WebhooksPage').then(m
const AuditLogsPage = lazy(() => import('@/pages/dashboard/AuditLogsPage').then(m => ({ default: m.AuditLogsPage })));
const FeatureFlagsPage = lazy(() => import('@/pages/dashboard/FeatureFlagsPage').then(m => ({ default: m.FeatureFlagsPage })));
// Lazy loaded pages - Sales
const SalesPage = lazy(() => import('@/pages/dashboard/sales').then(m => ({ default: m.SalesPage })));
const LeadsPage = lazy(() => import('@/pages/dashboard/sales').then(m => ({ default: m.LeadsPage })));
const LeadDetailPage = lazy(() => import('@/pages/dashboard/sales').then(m => ({ default: m.LeadDetailPage })));
const OpportunitiesPage = lazy(() => import('@/pages/dashboard/sales').then(m => ({ default: m.OpportunitiesPage })));
const OpportunityDetailPage = lazy(() => import('@/pages/dashboard/sales').then(m => ({ default: m.OpportunityDetailPage })));
const ActivitiesPage = lazy(() => import('@/pages/dashboard/sales').then(m => ({ default: m.ActivitiesPage })));
// Lazy loaded pages - Commissions
const CommissionsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.CommissionsPage })));
const SchemesPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.SchemesPage })));
const EntriesPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.EntriesPage })));
const PeriodsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.PeriodsPage })));
const MyEarningsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.MyEarningsPage })));
// Lazy loaded pages - Admin
const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
const AnalyticsDashboardPage = lazy(() => import('@/pages/admin/AnalyticsDashboardPage').then(m => ({ default: m.AnalyticsDashboardPage })));
@ -133,6 +148,21 @@ export function AppRouter() {
<Route path="feature-flags" element={<SuspensePage><FeatureFlagsPage /></SuspensePage>} />
<Route path="whatsapp" element={<SuspensePage><WhatsAppSettings /></SuspensePage>} />
<Route path="analytics" element={<SuspensePage><AnalyticsDashboardPage /></SuspensePage>} />
{/* Sales routes */}
<Route path="sales" element={<SuspensePage><SalesPage /></SuspensePage>} />
<Route path="sales/leads" element={<SuspensePage><LeadsPage /></SuspensePage>} />
<Route path="sales/leads/:id" element={<SuspensePage><LeadDetailPage /></SuspensePage>} />
<Route path="sales/opportunities" element={<SuspensePage><OpportunitiesPage /></SuspensePage>} />
<Route path="sales/opportunities/:id" element={<SuspensePage><OpportunityDetailPage /></SuspensePage>} />
<Route path="sales/activities" element={<SuspensePage><ActivitiesPage /></SuspensePage>} />
{/* Commissions routes */}
<Route path="commissions" element={<SuspensePage><CommissionsPage /></SuspensePage>} />
<Route path="commissions/schemes" element={<SuspensePage><SchemesPage /></SuspensePage>} />
<Route path="commissions/entries" element={<SuspensePage><EntriesPage /></SuspensePage>} />
<Route path="commissions/periods" element={<SuspensePage><PeriodsPage /></SuspensePage>} />
<Route path="commissions/my-earnings" element={<SuspensePage><MyEarningsPage /></SuspensePage>} />
</Route>
{/* Superadmin routes */}

View File

@ -0,0 +1,104 @@
import api from '../api';
import { CommissionScheme } from './schemes.api';
export interface CommissionAssignment {
id: string;
tenant_id: string;
user_id: string;
scheme_id: string;
starts_at: string;
ends_at: string | null;
custom_rate: number | null;
is_active: boolean;
created_at: string;
created_by: string | null;
scheme?: CommissionScheme;
user?: {
id: string;
first_name: string;
last_name: string;
email: string;
};
}
export interface CreateAssignmentDto {
user_id: string;
scheme_id: string;
starts_at?: string;
ends_at?: string;
custom_rate?: number;
is_active?: boolean;
}
export interface UpdateAssignmentDto {
ends_at?: string;
custom_rate?: number;
is_active?: boolean;
}
export interface AssignmentFilters {
user_id?: string;
scheme_id?: string;
is_active?: boolean;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedAssignments {
data: CommissionAssignment[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const assignmentsApi = {
list: async (params?: AssignmentFilters): Promise<PaginatedAssignments> => {
const response = await api.get<PaginatedAssignments>('/commissions/assignments', { params });
return response.data;
},
get: async (id: string): Promise<CommissionAssignment> => {
const response = await api.get<CommissionAssignment>(`/commissions/assignments/${id}`);
return response.data;
},
getByUser: async (userId: string): Promise<CommissionAssignment[]> => {
const response = await api.get<CommissionAssignment[]>(`/commissions/assignments/user/${userId}`);
return response.data;
},
getActiveScheme: async (userId: string): Promise<CommissionAssignment | null> => {
const response = await api.get<CommissionAssignment | { message: string }>(`/commissions/assignments/user/${userId}/active`);
if ('message' in response.data) {
return null;
}
return response.data;
},
getSchemeAssignees: async (schemeId: string): Promise<CommissionAssignment[]> => {
const response = await api.get<CommissionAssignment[]>(`/commissions/assignments/scheme/${schemeId}/users`);
return response.data;
},
assign: async (data: CreateAssignmentDto): Promise<CommissionAssignment> => {
const response = await api.post<CommissionAssignment>('/commissions/assignments', data);
return response.data;
},
update: async (id: string, data: UpdateAssignmentDto): Promise<CommissionAssignment> => {
const response = await api.patch<CommissionAssignment>(`/commissions/assignments/${id}`, data);
return response.data;
},
remove: async (id: string): Promise<void> => {
await api.delete(`/commissions/assignments/${id}`);
},
deactivate: async (id: string): Promise<CommissionAssignment> => {
const response = await api.post<CommissionAssignment>(`/commissions/assignments/${id}/deactivate`);
return response.data;
},
};

View File

@ -0,0 +1,101 @@
import api from '../api';
import { CommissionEntry } from './entries.api';
import { PeriodStatus } from './periods.api';
export interface DashboardSummary {
total_pending: number;
total_approved: number;
total_paid: number;
pending_count: number;
approved_count: number;
paid_count: number;
active_schemes: number;
active_assignments: number;
open_periods: number;
currency: string;
}
export interface UserEarnings {
user_id: string;
user_name: string;
user_email: string;
pending: number;
approved: number;
paid: number;
total: number;
entries_count: number;
}
export interface PeriodEarnings {
period_id: string;
period_name: string;
starts_at: string;
ends_at: string;
status: PeriodStatus;
total_amount: number;
entries_count: number;
}
export interface TopEarner {
user_id: string;
user_name: string;
total_earned: number;
entries_count: number;
rank: number;
}
export interface MyEarnings {
pending: number;
approved: number;
paid: number;
total: number;
current_period_earnings: number;
last_paid_date: string | null;
entries_count: number;
recent_entries: CommissionEntry[];
}
export interface SchemePerformance {
total_generated: number;
total_paid: number;
entries_count: number;
avg_commission: number;
active_users: number;
}
export const dashboardApi = {
getSummary: async (): Promise<DashboardSummary> => {
const response = await api.get<DashboardSummary>('/commissions/dashboard/summary');
return response.data;
},
getEarningsByUser: async (params?: { date_from?: string; date_to?: string }): Promise<UserEarnings[]> => {
const response = await api.get<UserEarnings[]>('/commissions/dashboard/by-user', { params });
return response.data;
},
getEarningsByPeriod: async (): Promise<PeriodEarnings[]> => {
const response = await api.get<PeriodEarnings[]>('/commissions/dashboard/by-period');
return response.data;
},
getTopEarners: async (params?: { limit?: number; date_from?: string; date_to?: string }): Promise<TopEarner[]> => {
const response = await api.get<TopEarner[]>('/commissions/dashboard/top-earners', { params });
return response.data;
},
getMyEarnings: async (): Promise<MyEarnings> => {
const response = await api.get<MyEarnings>('/commissions/dashboard/my-earnings');
return response.data;
},
getUserEarnings: async (userId: string): Promise<MyEarnings> => {
const response = await api.get<MyEarnings>(`/commissions/dashboard/user/${userId}/earnings`);
return response.data;
},
getSchemePerformance: async (schemeId: string): Promise<SchemePerformance> => {
const response = await api.get<SchemePerformance>(`/commissions/dashboard/scheme/${schemeId}/performance`);
return response.data;
},
};

View File

@ -0,0 +1,147 @@
import api from '../api';
import { CommissionScheme } from './schemes.api';
export type EntryStatus = 'pending' | 'approved' | 'rejected' | 'paid' | 'cancelled';
export interface CommissionEntry {
id: string;
tenant_id: string;
user_id: string;
scheme_id: string;
assignment_id: string | null;
reference_type: string;
reference_id: string;
base_amount: number;
rate_applied: number;
commission_amount: number;
currency: string;
status: EntryStatus;
period_id: string | null;
paid_at: string | null;
payment_reference: string | null;
notes: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
approved_by: string | null;
approved_at: string | null;
scheme?: CommissionScheme;
user?: {
id: string;
first_name: string;
last_name: string;
email: string;
};
}
export interface CalculateCommissionDto {
user_id: string;
reference_type: string;
reference_id: string;
base_amount: number;
currency?: string;
notes?: string;
metadata?: Record<string, unknown>;
}
export interface UpdateEntryStatusDto {
status: EntryStatus;
notes?: string;
}
export interface CalculationResult {
rate_applied: number;
commission_amount: number;
scheme_id: string;
assignment_id: string | null;
}
export interface EntryFilters {
user_id?: string;
scheme_id?: string;
period_id?: string;
status?: EntryStatus;
reference_type?: string;
date_from?: string;
date_to?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedEntries {
data: CommissionEntry[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const entriesApi = {
list: async (params?: EntryFilters): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>('/commissions/entries', { params });
return response.data;
},
listPending: async (params?: { page?: number; limit?: number }): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>('/commissions/entries/pending', { params });
return response.data;
},
get: async (id: string): Promise<CommissionEntry> => {
const response = await api.get<CommissionEntry>(`/commissions/entries/${id}`);
return response.data;
},
getByUser: async (userId: string, params?: { page?: number; limit?: number }): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>(`/commissions/entries/user/${userId}`, { params });
return response.data;
},
getByPeriod: async (periodId: string, params?: { page?: number; limit?: number }): Promise<PaginatedEntries> => {
const response = await api.get<PaginatedEntries>(`/commissions/entries/period/${periodId}`, { params });
return response.data;
},
getByReference: async (type: string, refId: string): Promise<CommissionEntry | null> => {
const response = await api.get<CommissionEntry | { message: string }>(`/commissions/entries/reference/${type}/${refId}`);
if ('message' in response.data) {
return null;
}
return response.data;
},
calculate: async (data: CalculateCommissionDto): Promise<CommissionEntry> => {
const response = await api.post<CommissionEntry>('/commissions/entries/calculate', data);
return response.data;
},
simulate: async (data: CalculateCommissionDto): Promise<CalculationResult> => {
const response = await api.post<CalculationResult>('/commissions/entries/simulate', data);
return response.data;
},
updateStatus: async (id: string, data: UpdateEntryStatusDto): Promise<CommissionEntry> => {
const response = await api.patch<CommissionEntry>(`/commissions/entries/${id}/status`, data);
return response.data;
},
bulkApprove: async (entryIds: string[]): Promise<{ approved: number; failed: number }> => {
const response = await api.post<{ approved: number; failed: number }>('/commissions/entries/bulk-approve', { entry_ids: entryIds });
return response.data;
},
bulkReject: async (entryIds: string[], reason?: string): Promise<{ rejected: number; failed: number }> => {
const response = await api.post<{ rejected: number; failed: number }>('/commissions/entries/bulk-reject', { entry_ids: entryIds, reason });
return response.data;
},
cancelByReference: async (type: string, refId: string, reason?: string): Promise<CommissionEntry | null> => {
const response = await api.post<CommissionEntry | { message: string }>(`/commissions/entries/cancel/${type}/${refId}`, { reason });
if ('message' in response.data) {
return null;
}
return response.data;
},
};

View File

@ -0,0 +1,5 @@
export * from './schemes.api';
export * from './assignments.api';
export * from './entries.api';
export * from './periods.api';
export * from './dashboard.api';

View File

@ -0,0 +1,121 @@
import api from '../api';
export type PeriodStatus = 'open' | 'closed' | 'processing' | 'paid';
export interface CommissionPeriod {
id: string;
tenant_id: string;
name: string;
starts_at: string;
ends_at: string;
total_entries: number;
total_amount: number;
currency: string;
status: PeriodStatus;
closed_at: string | null;
closed_by: string | null;
paid_at: string | null;
paid_by: string | null;
payment_reference: string | null;
payment_notes: string | null;
created_at: string;
created_by: string | null;
}
export interface CreatePeriodDto {
name: string;
starts_at: string;
ends_at: string;
currency?: string;
}
export interface MarkPeriodPaidDto {
payment_reference?: string;
payment_notes?: string;
}
export interface PeriodFilters {
status?: PeriodStatus;
date_from?: string;
date_to?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedPeriods {
data: CommissionPeriod[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PeriodSummary {
total_entries: number;
total_amount: number;
by_status: {
pending: number;
approved: number;
rejected: number;
paid: number;
cancelled: number;
};
by_user: Array<{
user_id: string;
user_name: string;
entries_count: number;
total_amount: number;
}>;
}
export const periodsApi = {
list: async (params?: PeriodFilters): Promise<PaginatedPeriods> => {
const response = await api.get<PaginatedPeriods>('/commissions/periods', { params });
return response.data;
},
getOpen: async (): Promise<CommissionPeriod | null> => {
const response = await api.get<CommissionPeriod | { message: string }>('/commissions/periods/open');
if ('message' in response.data) {
return null;
}
return response.data;
},
get: async (id: string): Promise<CommissionPeriod> => {
const response = await api.get<CommissionPeriod>(`/commissions/periods/${id}`);
return response.data;
},
getSummary: async (id: string): Promise<PeriodSummary> => {
const response = await api.get<PeriodSummary>(`/commissions/periods/${id}/summary`);
return response.data;
},
create: async (data: CreatePeriodDto): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>('/commissions/periods', data);
return response.data;
},
close: async (id: string): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/close`);
return response.data;
},
reopen: async (id: string): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/reopen`);
return response.data;
},
markAsProcessing: async (id: string): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/processing`);
return response.data;
},
markAsPaid: async (id: string, data?: MarkPeriodPaidDto): Promise<CommissionPeriod> => {
const response = await api.post<CommissionPeriod>(`/commissions/periods/${id}/pay`, data || {});
return response.data;
},
};

View File

@ -0,0 +1,107 @@
import api from '../api';
// Types
export type SchemeType = 'percentage' | 'fixed' | 'tiered';
export type AppliesTo = 'all' | 'products' | 'categories';
export interface TierConfig {
from: number;
to: number | null;
rate: number;
}
export interface CommissionScheme {
id: string;
tenant_id: string;
name: string;
description: string | null;
type: SchemeType;
rate: number;
fixed_amount: number;
tiers: TierConfig[];
applies_to: AppliesTo;
product_ids: string[];
category_ids: string[];
min_amount: number;
max_amount: number | null;
is_active: boolean;
created_at: string;
updated_at: string;
created_by: string | null;
}
export interface CreateSchemeDto {
name: string;
description?: string;
type: SchemeType;
rate?: number;
fixed_amount?: number;
tiers?: TierConfig[];
applies_to?: AppliesTo;
product_ids?: string[];
category_ids?: string[];
min_amount?: number;
max_amount?: number;
is_active?: boolean;
}
export type UpdateSchemeDto = Partial<CreateSchemeDto>;
export interface SchemeFilters {
type?: SchemeType;
is_active?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedSchemes {
data: CommissionScheme[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export const schemesApi = {
list: async (params?: SchemeFilters): Promise<PaginatedSchemes> => {
const response = await api.get<PaginatedSchemes>('/commissions/schemes', { params });
return response.data;
},
listActive: async (): Promise<CommissionScheme[]> => {
const response = await api.get<CommissionScheme[]>('/commissions/schemes/active');
return response.data;
},
get: async (id: string): Promise<CommissionScheme> => {
const response = await api.get<CommissionScheme>(`/commissions/schemes/${id}`);
return response.data;
},
create: async (data: CreateSchemeDto): Promise<CommissionScheme> => {
const response = await api.post<CommissionScheme>('/commissions/schemes', data);
return response.data;
},
update: async (id: string, data: UpdateSchemeDto): Promise<CommissionScheme> => {
const response = await api.patch<CommissionScheme>(`/commissions/schemes/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/commissions/schemes/${id}`);
},
duplicate: async (id: string, name?: string): Promise<CommissionScheme> => {
const response = await api.post<CommissionScheme>(`/commissions/schemes/${id}/duplicate`, { name });
return response.data;
},
toggleActive: async (id: string): Promise<CommissionScheme> => {
const response = await api.post<CommissionScheme>(`/commissions/schemes/${id}/toggle-active`);
return response.data;
},
};

View File

@ -0,0 +1,156 @@
import api from '../api';
// Types
export type ActivityType = 'call' | 'meeting' | 'task' | 'email' | 'note';
export type ActivityStatus = 'pending' | 'completed' | 'cancelled';
export interface Activity {
id: string;
tenant_id: string;
type: ActivityType;
status: ActivityStatus;
subject: string;
description: string | null;
lead_id: string | null;
opportunity_id: string | null;
due_date: string | null;
due_time: string | null;
duration_minutes: number | null;
completed_at: string | null;
outcome: string | null;
assigned_to: string | null;
created_by: string | null;
call_direction: 'inbound' | 'outbound' | null;
call_recording_url: string | null;
location: string | null;
meeting_url: string | null;
attendees: Array<{
name: string;
email: string;
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
}>;
reminder_at: string | null;
reminder_sent: boolean;
custom_fields: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface CreateActivityDto {
type: ActivityType;
subject: string;
description?: string;
lead_id?: string;
opportunity_id?: string;
due_date?: string;
due_time?: string;
duration_minutes?: number;
assigned_to?: string;
call_direction?: 'inbound' | 'outbound';
location?: string;
meeting_url?: string;
attendees?: Array<{
name: string;
email: string;
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
}>;
reminder_at?: string;
custom_fields?: Record<string, unknown>;
}
export type UpdateActivityDto = Partial<CreateActivityDto>;
export interface ActivityFilters {
type?: ActivityType;
status?: ActivityStatus;
lead_id?: string;
opportunity_id?: string;
assigned_to?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedActivities {
data: Activity[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ActivityStats {
total: number;
pending: number;
completed: number;
overdue: number;
byType: Record<ActivityType, number>;
}
export const activitiesApi = {
list: async (params?: ActivityFilters): Promise<PaginatedActivities> => {
const response = await api.get<PaginatedActivities>('/sales/activities', { params });
return response.data;
},
get: async (id: string): Promise<Activity> => {
const response = await api.get<Activity>(`/sales/activities/${id}`);
return response.data;
},
create: async (data: CreateActivityDto): Promise<Activity> => {
const response = await api.post<Activity>('/sales/activities', data);
return response.data;
},
update: async (id: string, data: UpdateActivityDto): Promise<Activity> => {
const response = await api.patch<Activity>(`/sales/activities/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/sales/activities/${id}`);
},
complete: async (id: string, outcome?: string): Promise<Activity> => {
const response = await api.post<Activity>(`/sales/activities/${id}/complete`, { outcome });
return response.data;
},
cancel: async (id: string): Promise<Activity> => {
const response = await api.post<Activity>(`/sales/activities/${id}/cancel`);
return response.data;
},
getByLead: async (leadId: string): Promise<Activity[]> => {
const response = await api.get<Activity[]>(`/sales/activities/lead/${leadId}`);
return response.data;
},
getByOpportunity: async (opportunityId: string): Promise<Activity[]> => {
const response = await api.get<Activity[]>(`/sales/activities/opportunity/${opportunityId}`);
return response.data;
},
getUpcoming: async (days?: number, userId?: string): Promise<Activity[]> => {
const response = await api.get<Activity[]>('/sales/activities/upcoming', {
params: { days, user_id: userId },
});
return response.data;
},
getOverdue: async (userId?: string): Promise<Activity[]> => {
const response = await api.get<Activity[]>('/sales/activities/overdue', {
params: { user_id: userId },
});
return response.data;
},
getStats: async (userId?: string): Promise<ActivityStats> => {
const response = await api.get<ActivityStats>('/sales/activities/stats', {
params: { user_id: userId },
});
return response.data;
},
};

View File

@ -0,0 +1,108 @@
import api from '../api';
import { OpportunityStage } from './opportunities.api';
// Types
export interface SalesSummary {
leads: {
total: number;
new: number;
qualified: number;
converted: number;
conversionRate: number;
};
opportunities: {
total: number;
open: number;
won: number;
lost: number;
totalValue: number;
wonValue: number;
avgDealSize: number;
winRate: number;
};
activities: {
total: number;
pending: number;
completed: number;
overdue: number;
};
pipeline: {
totalValue: number;
weightedValue: number;
byStage: Array<{
stage: OpportunityStage;
count: number;
value: number;
}>;
};
}
export interface ConversionRates {
leadToOpportunity: number;
opportunityToWon: number;
overall: number;
bySource: Array<{
source: string;
leads: number;
converted: number;
rate: number;
}>;
byMonth: Array<{
month: string;
leads: number;
opportunities: number;
won: number;
}>;
}
export interface RevenueReport {
total: number;
byMonth: Array<{
month: string;
revenue: number;
deals: number;
}>;
byUser: Array<{
userId: string;
userName: string;
revenue: number;
deals: number;
}>;
}
export interface TopSeller {
userId: string;
userName: string;
revenue: number;
deals: number;
avgDealSize: number;
winRate: number;
}
export const salesDashboardApi = {
getSummary: async (): Promise<SalesSummary> => {
const response = await api.get<SalesSummary>('/sales/dashboard');
return response.data;
},
getConversionRates: async (startDate?: string, endDate?: string): Promise<ConversionRates> => {
const response = await api.get<ConversionRates>('/sales/dashboard/conversion', {
params: { start_date: startDate, end_date: endDate },
});
return response.data;
},
getRevenue: async (startDate: string, endDate: string): Promise<RevenueReport> => {
const response = await api.get<RevenueReport>('/sales/dashboard/revenue', {
params: { start_date: startDate, end_date: endDate },
});
return response.data;
},
getTopSellers: async (limit?: number, startDate?: string, endDate?: string): Promise<TopSeller[]> => {
const response = await api.get<TopSeller[]>('/sales/dashboard/top-sellers', {
params: { limit, start_date: startDate, end_date: endDate },
});
return response.data;
},
};

View File

@ -0,0 +1,5 @@
export * from './leads.api';
export * from './opportunities.api';
export * from './activities.api';
export * from './pipeline.api';
export * from './dashboard.api';

View File

@ -0,0 +1,137 @@
import api from '../api';
// Types
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'unqualified' | 'converted';
export type LeadSource = 'website' | 'referral' | 'cold_call' | 'event' | 'advertisement' | 'social_media' | 'other';
export interface Lead {
id: string;
tenant_id: string;
first_name: string;
last_name: string;
email: string | null;
phone: string | null;
company: string | null;
job_title: string | null;
website: string | null;
source: LeadSource;
status: LeadStatus;
score: number;
assigned_to: string | null;
notes: string | null;
converted_at: string | null;
converted_to_opportunity_id: string | null;
address_line1: string | null;
address_line2: string | null;
city: string | null;
state: string | null;
postal_code: string | null;
country: string | null;
custom_fields: Record<string, unknown>;
created_at: string;
updated_at: string;
created_by: string | null;
}
export interface CreateLeadDto {
first_name: string;
last_name: string;
email?: string;
phone?: string;
company?: string;
job_title?: string;
website?: string;
source?: LeadSource;
status?: LeadStatus;
score?: number;
assigned_to?: string;
notes?: string;
address_line1?: string;
address_line2?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
custom_fields?: Record<string, unknown>;
}
export type UpdateLeadDto = Partial<CreateLeadDto>;
export interface ConvertLeadDto {
opportunity_name?: string;
amount?: number;
currency?: string;
expected_close_date?: string;
}
export interface LeadFilters {
status?: LeadStatus;
source?: LeadSource;
assigned_to?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedLeads {
data: Lead[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface LeadStats {
total: number;
byStatus: Record<LeadStatus, number>;
bySource: Record<LeadSource, number>;
avgScore: number;
}
export const leadsApi = {
list: async (params?: LeadFilters): Promise<PaginatedLeads> => {
const response = await api.get<PaginatedLeads>('/sales/leads', { params });
return response.data;
},
get: async (id: string): Promise<Lead> => {
const response = await api.get<Lead>(`/sales/leads/${id}`);
return response.data;
},
create: async (data: CreateLeadDto): Promise<Lead> => {
const response = await api.post<Lead>('/sales/leads', data);
return response.data;
},
update: async (id: string, data: UpdateLeadDto): Promise<Lead> => {
const response = await api.patch<Lead>(`/sales/leads/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/sales/leads/${id}`);
},
convert: async (id: string, data: ConvertLeadDto): Promise<any> => {
const response = await api.post(`/sales/leads/${id}/convert`, data);
return response.data;
},
assign: async (id: string, userId: string): Promise<Lead> => {
const response = await api.patch<Lead>(`/sales/leads/${id}/assign`, { user_id: userId });
return response.data;
},
updateScore: async (id: string, score: number): Promise<Lead> => {
const response = await api.patch<Lead>(`/sales/leads/${id}/score`, { score });
return response.data;
},
getStats: async (): Promise<LeadStats> => {
const response = await api.get<LeadStats>('/sales/leads/stats');
return response.data;
},
};

View File

@ -0,0 +1,165 @@
import api from '../api';
// Types
export type OpportunityStage = 'prospecting' | 'qualification' | 'proposal' | 'negotiation' | 'closed_won' | 'closed_lost';
export interface Opportunity {
id: string;
tenant_id: string;
name: string;
description: string | null;
lead_id: string | null;
stage: OpportunityStage;
stage_id: string | null;
amount: number;
currency: string;
probability: number;
expected_close_date: string | null;
actual_close_date: string | null;
assigned_to: string | null;
won_at: string | null;
lost_at: string | null;
lost_reason: string | null;
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
company_name: string | null;
notes: string | null;
custom_fields: Record<string, unknown>;
created_at: string;
updated_at: string;
created_by: string | null;
}
export interface CreateOpportunityDto {
name: string;
description?: string;
lead_id?: string;
stage?: OpportunityStage;
stage_id?: string;
amount?: number;
currency?: string;
probability?: number;
expected_close_date?: string;
assigned_to?: string;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
company_name?: string;
notes?: string;
custom_fields?: Record<string, unknown>;
}
export type UpdateOpportunityDto = Partial<CreateOpportunityDto>;
export interface MoveOpportunityDto {
stage: OpportunityStage;
stage_id?: string;
notes?: string;
}
export interface OpportunityFilters {
stage?: OpportunityStage;
stage_id?: string;
assigned_to?: string;
min_amount?: number;
max_amount?: number;
is_open?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedOpportunities {
data: Opportunity[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PipelineView {
stage: OpportunityStage;
stageName: string;
opportunities: Opportunity[];
count: number;
totalAmount: number;
}
export interface OpportunityStats {
total: number;
open: number;
won: number;
lost: number;
totalValue: number;
wonValue: number;
avgDealSize: number;
winRate: number;
}
export interface ForecastData {
totalPipeline: number;
weightedPipeline: number;
expectedRevenue: number;
byMonth: Array<{ month: string; amount: number; weighted: number }>;
}
export const opportunitiesApi = {
list: async (params?: OpportunityFilters): Promise<PaginatedOpportunities> => {
const response = await api.get<PaginatedOpportunities>('/sales/opportunities', { params });
return response.data;
},
get: async (id: string): Promise<Opportunity> => {
const response = await api.get<Opportunity>(`/sales/opportunities/${id}`);
return response.data;
},
create: async (data: CreateOpportunityDto): Promise<Opportunity> => {
const response = await api.post<Opportunity>('/sales/opportunities', data);
return response.data;
},
update: async (id: string, data: UpdateOpportunityDto): Promise<Opportunity> => {
const response = await api.patch<Opportunity>(`/sales/opportunities/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/sales/opportunities/${id}`);
},
move: async (id: string, data: MoveOpportunityDto): Promise<Opportunity> => {
const response = await api.post<Opportunity>(`/sales/opportunities/${id}/move`, data);
return response.data;
},
markAsWon: async (id: string, notes?: string): Promise<Opportunity> => {
const response = await api.post<Opportunity>(`/sales/opportunities/${id}/won`, { notes });
return response.data;
},
markAsLost: async (id: string, reason?: string): Promise<Opportunity> => {
const response = await api.post<Opportunity>(`/sales/opportunities/${id}/lost`, { reason });
return response.data;
},
getByStage: async (): Promise<PipelineView[]> => {
const response = await api.get<PipelineView[]>('/sales/opportunities/pipeline');
return response.data;
},
getStats: async (): Promise<OpportunityStats> => {
const response = await api.get<OpportunityStats>('/sales/opportunities/stats');
return response.data;
},
getForecast: async (startDate: string, endDate: string): Promise<ForecastData> => {
const response = await api.get<ForecastData>('/sales/opportunities/forecast', {
params: { start_date: startDate, end_date: endDate },
});
return response.data;
},
};

View File

@ -0,0 +1,67 @@
import api from '../api';
// Types
export interface PipelineStage {
id: string;
tenant_id: string;
name: string;
position: number;
color: string;
is_won: boolean;
is_lost: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
opportunityCount?: number;
totalAmount?: number;
}
export interface CreatePipelineStageDto {
name: string;
position?: number;
color?: string;
is_won?: boolean;
is_lost?: boolean;
}
export interface UpdatePipelineStageDto {
name?: string;
color?: string;
is_active?: boolean;
}
export const pipelineApi = {
getStages: async (): Promise<PipelineStage[]> => {
const response = await api.get<PipelineStage[]>('/sales/pipeline/stages');
return response.data;
},
getStage: async (id: string): Promise<PipelineStage> => {
const response = await api.get<PipelineStage>(`/sales/pipeline/stages/${id}`);
return response.data;
},
createStage: async (data: CreatePipelineStageDto): Promise<PipelineStage> => {
const response = await api.post<PipelineStage>('/sales/pipeline/stages', data);
return response.data;
},
updateStage: async (id: string, data: UpdatePipelineStageDto): Promise<PipelineStage> => {
const response = await api.patch<PipelineStage>(`/sales/pipeline/stages/${id}`, data);
return response.data;
},
deleteStage: async (id: string): Promise<void> => {
await api.delete(`/sales/pipeline/stages/${id}`);
},
reorderStages: async (stageIds: string[]): Promise<PipelineStage[]> => {
const response = await api.post<PipelineStage[]>('/sales/pipeline/reorder', { stage_ids: stageIds });
return response.data;
},
initializeDefaults: async (): Promise<PipelineStage[]> => {
const response = await api.post<PipelineStage[]>('/sales/pipeline/initialize');
return response.data;
},
};