[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:
parent
f59bbfac64
commit
36ee5213c5
402
src/hooks/useCommissions.ts
Normal file
402
src/hooks/useCommissions.ts
Normal 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
398
src/hooks/useSales.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
211
src/pages/dashboard/commissions/CommissionsPage.tsx
Normal file
211
src/pages/dashboard/commissions/CommissionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/pages/dashboard/commissions/EntriesPage.tsx
Normal file
307
src/pages/dashboard/commissions/EntriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
src/pages/dashboard/commissions/MyEarningsPage.tsx
Normal file
214
src/pages/dashboard/commissions/MyEarningsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/pages/dashboard/commissions/PeriodsPage.tsx
Normal file
261
src/pages/dashboard/commissions/PeriodsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/pages/dashboard/commissions/SchemesPage.tsx
Normal file
187
src/pages/dashboard/commissions/SchemesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/pages/dashboard/commissions/index.ts
Normal file
5
src/pages/dashboard/commissions/index.ts
Normal 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';
|
||||
188
src/pages/dashboard/sales/ActivitiesPage.tsx
Normal file
188
src/pages/dashboard/sales/ActivitiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
376
src/pages/dashboard/sales/LeadDetailPage.tsx
Normal file
376
src/pages/dashboard/sales/LeadDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
src/pages/dashboard/sales/LeadsPage.tsx
Normal file
184
src/pages/dashboard/sales/LeadsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/pages/dashboard/sales/OpportunitiesPage.tsx
Normal file
105
src/pages/dashboard/sales/OpportunitiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
src/pages/dashboard/sales/OpportunityDetailPage.tsx
Normal file
379
src/pages/dashboard/sales/OpportunityDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/pages/dashboard/sales/SalesPage.tsx
Normal file
163
src/pages/dashboard/sales/SalesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/pages/dashboard/sales/index.ts
Normal file
6
src/pages/dashboard/sales/index.ts
Normal 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';
|
||||
@ -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 */}
|
||||
|
||||
104
src/services/commissions/assignments.api.ts
Normal file
104
src/services/commissions/assignments.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
101
src/services/commissions/dashboard.api.ts
Normal file
101
src/services/commissions/dashboard.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
147
src/services/commissions/entries.api.ts
Normal file
147
src/services/commissions/entries.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
5
src/services/commissions/index.ts
Normal file
5
src/services/commissions/index.ts
Normal 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';
|
||||
121
src/services/commissions/periods.api.ts
Normal file
121
src/services/commissions/periods.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
107
src/services/commissions/schemes.api.ts
Normal file
107
src/services/commissions/schemes.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
156
src/services/sales/activities.api.ts
Normal file
156
src/services/sales/activities.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
108
src/services/sales/dashboard.api.ts
Normal file
108
src/services/sales/dashboard.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
5
src/services/sales/index.ts
Normal file
5
src/services/sales/index.ts
Normal 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';
|
||||
137
src/services/sales/leads.api.ts
Normal file
137
src/services/sales/leads.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
165
src/services/sales/opportunities.api.ts
Normal file
165
src/services/sales/opportunities.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
67
src/services/sales/pipeline.api.ts
Normal file
67
src/services/sales/pipeline.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user