template-saas-frontend-v2/src/pages/dashboard/commissions/PeriodsPage.tsx
Adrian Flores Cortes 36ee5213c5 [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>
2026-01-24 22:50:11 -06:00

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>
);
}