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>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|