mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Edit, Eye, Trash2 } from 'lucide-react';
|
|
import DataTable from '@/components/ui/DataTable';
|
|
import ActionButton from '@/components/ui/ActionButton';
|
|
import Badge from '@/components/ui/Badge';
|
|
import Modal from '@/components/ui/Modal';
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
import { agentsApi, apiClient } from '@/lib/api';
|
|
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
|
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
|
<div className="bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
|
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
|
</div>
|
|
);
|
|
|
|
const SectionHeader = ({ title }: { title: string }) => (
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
|
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
|
</h3>
|
|
);
|
|
|
|
export default function AgentsPage() {
|
|
const { user } = useAuthStore();
|
|
const queryClient = useQueryClient();
|
|
const [filters, setFilters] = useState({ search: '', active: '' });
|
|
const [selected, setSelected] = useState<any>(null);
|
|
const [createModal, setCreateModal] = useState(false);
|
|
const [createForm, setCreateForm] = useState({ iamUserId: '', agentCode: '', commissionRate: '5' });
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: any) => apiClient.post('/agents', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
|
setCreateModal(false);
|
|
setCreateError(null);
|
|
},
|
|
onError: (e: any) => setCreateError(e?.response?.data?.message || e?.message || 'Failed to create agent'),
|
|
});
|
|
|
|
const [editModal, setEditModal] = useState(false);
|
|
const [editForm, setEditForm] = useState({ agentCode: '', commissionRate: '5', active: true });
|
|
const [editingAgent, setEditingAgent] = useState<any>(null);
|
|
const [editError, setEditError] = useState<string | null>(null);
|
|
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null });
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => agentsApi.delete(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
|
setDeleteConfirm({ isOpen: false, agent: null });
|
|
setDeleteError(null);
|
|
},
|
|
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'),
|
|
});
|
|
|
|
const editMutation = useMutation({
|
|
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
|
setEditModal(false);
|
|
setEditError(null);
|
|
},
|
|
onError: (e: any) => setEditError(e?.response?.data?.message || e?.message || 'Failed to update agent'),
|
|
});
|
|
|
|
const openCreateModal = () => {
|
|
setCreateForm({ iamUserId: '', agentCode: '', commissionRate: '5' });
|
|
setCreateError(null);
|
|
setCreateModal(true);
|
|
};
|
|
|
|
const openEditModal = (agent: any) => {
|
|
setEditingAgent(agent);
|
|
setEditForm({ agentCode: agent.agentCode, commissionRate: String(agent.commissionRate ?? 5), active: agent.active });
|
|
setEditError(null);
|
|
setEditModal(true);
|
|
};
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['agents', filters],
|
|
queryFn: () => agentsApi.getAll(filters),
|
|
});
|
|
|
|
const columns = [
|
|
{
|
|
key: 'agentCode',
|
|
label: 'Agent Code',
|
|
sortable: true,
|
|
render: (agent: any) => <span className="font-mono font-semibold">{agent.agentCode}</span>,
|
|
},
|
|
{
|
|
key: 'user',
|
|
label: 'Name',
|
|
render: (agent: any) => (
|
|
<div>
|
|
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
|
|
<div className="text-sm text-muted-foreground">{agent.user?.email}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'commissionRate',
|
|
label: 'Commission',
|
|
render: (agent: any) => <span>{agent.commissionRate}%</span>,
|
|
},
|
|
{
|
|
key: 'active',
|
|
label: 'Status',
|
|
render: (agent: any) => (
|
|
<Badge variant="status" status={agent.active ? 'CONFIRMED' : 'CANCELLED'}>
|
|
{agent.active ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
),
|
|
},
|
|
];
|
|
|
|
const actions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: (agent: any) => openEditModal(agent),
|
|
variant: 'secondary' as const,
|
|
icon: Edit,
|
|
},
|
|
{
|
|
label: 'Details',
|
|
onClick: (agent: any) => setSelected(agent),
|
|
variant: 'secondary' as const,
|
|
icon: Eye,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Agents</h1>
|
|
<p className="text-muted-foreground">Manage agents and their operations</p>
|
|
</div>
|
|
<ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="label">Search</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Search agents..."
|
|
className="input"
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select
|
|
className="input"
|
|
value={filters.active}
|
|
onChange={(e) => setFilters({ ...filters, active: e.target.value })}
|
|
>
|
|
<option value="">All Status</option>
|
|
<option value="true">Active</option>
|
|
<option value="false">Inactive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
data={data?.items || []}
|
|
columns={columns}
|
|
actions={actions}
|
|
loading={isLoading}
|
|
emptyMessage="No agents found"
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }}
|
|
onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }}
|
|
title="Delete Agent"
|
|
message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`}
|
|
confirmText="Delete"
|
|
isDanger
|
|
isLoading={deleteMutation.isPending}
|
|
error={deleteError ?? undefined}
|
|
/>
|
|
|
|
{/* Agent Details Modal */}
|
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
|
|
{selected && (() => {
|
|
const a = selected;
|
|
const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
|
|
return (
|
|
<div>
|
|
<div className="from-emerald-600 to-emerald-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
|
<span className="text-white text-xl font-bold">{initials}</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-white text-xl font-bold truncate">{a.user?.fullName || 'N/A'}</p>
|
|
<p className="text-emerald-200 text-sm font-mono">{a.agentCode}</p>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
|
|
{a.active ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
|
{[
|
|
{ label: 'Agent Code', value: a.agentCode || '—' },
|
|
{ label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` },
|
|
{ label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() },
|
|
].map(({ label, value }) => (
|
|
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
|
<p className="text-emerald-200 text-xs">{label}</p>
|
|
<p className="text-white text-sm font-bold truncate">{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<section>
|
|
<SectionHeader title="Agent Information" />
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Field label="Agent Code" value={a.agentCode} mono />
|
|
<Field label="Commission Rate" value={`${a.commissionRate ?? 0}%`} />
|
|
<Field label="Counter Location" value={a.counterLocation || a.location || 'N/A'} />
|
|
<div className="bg-muted/40 rounded-lg p-3">
|
|
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
|
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
|
|
{a.active ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHeader title="User Account" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Full Name" value={a.user?.fullName} />
|
|
<Field label="Email" value={a.user?.email} truncate />
|
|
<Field label="Phone" value={a.user?.phone} />
|
|
<Field label="Role" value={a.user?.role || 'AGENT'} />
|
|
<Field label="User ID" value={a.userId || a.user?.id} mono truncate />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHeader title="Performance" />
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Field label="Total Bookings" value={(a.totalBookings ?? 0).toLocaleString()} />
|
|
<Field label="Total Revenue" value={a.totalRevenue ? formatCurrency(a.totalRevenue, 'ETB') : 'N/A'} />
|
|
<Field label="Total Commission" value={a.totalCommission ? formatCurrency(a.totalCommission, 'ETB') : 'N/A'} />
|
|
<Field label="Pending Commission" value={a.pendingCommission ? formatCurrency(a.pendingCommission, 'ETB') : 'N/A'} />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHeader title="Timestamps & IDs" />
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<Field label="Agent Since" value={formatDateTime(a.createdAt)} />
|
|
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
|
|
<Field label="Agent ID" value={a.id} mono truncate />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
|
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</Modal>
|
|
{/* Create Agent Modal */}
|
|
<Modal isOpen={createModal} onClose={() => setCreateModal(false)} title="Add Agent Profile" size="md">
|
|
<div className="space-y-4">
|
|
{createError && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
|
{createError}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="label">IAM User ID *</label>
|
|
<input
|
|
className="input"
|
|
value={createForm.iamUserId}
|
|
onChange={(e) => setCreateForm({ ...createForm, iamUserId: e.target.value })}
|
|
placeholder="IAM user UUID"
|
|
/>
|
|
{user?.id && createForm.iamUserId === user.id && (
|
|
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">✓ Pre-filled with your logged-in user ID</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground mt-1">Links this agent profile to an IAM back-office user</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Agent Code (optional)</label>
|
|
<input
|
|
className="input"
|
|
value={createForm.agentCode}
|
|
onChange={(e) => setCreateForm({ ...createForm, agentCode: e.target.value })}
|
|
placeholder="e.g. AG0002 (auto-generated if empty)"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Commission Rate (%)</label>
|
|
<input
|
|
type="number" min="0" max="100" className="input"
|
|
value={createForm.commissionRate}
|
|
onChange={(e) => setCreateForm({ ...createForm, commissionRate: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<ActionButton variant="secondary" onClick={() => setCreateModal(false)}>Cancel</ActionButton>
|
|
<ActionButton
|
|
loading={createMutation.isPending}
|
|
onClick={() => createMutation.mutate({
|
|
iamUserId: createForm.iamUserId,
|
|
agentCode: createForm.agentCode || undefined,
|
|
commissionRate: parseInt(createForm.commissionRate) || 5,
|
|
})}
|
|
>
|
|
Create Agent
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
{/* Edit Agent Modal */}
|
|
<Modal isOpen={editModal} onClose={() => setEditModal(false)} title="Edit Agent" size="md">
|
|
<div className="space-y-4">
|
|
{editError && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
|
{editError}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Agent Code</label>
|
|
<input className="input" value={editForm.agentCode}
|
|
onChange={(e) => setEditForm({ ...editForm, agentCode: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<label className="label">Commission Rate (%)</label>
|
|
<input type="number" min="0" max="100" className="input" value={editForm.commissionRate}
|
|
onChange={(e) => setEditForm({ ...editForm, commissionRate: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select className="input" value={editForm.active ? 'true' : 'false'}
|
|
onChange={(e) => setEditForm({ ...editForm, active: e.target.value === 'true' })}>
|
|
<option value="true">Active</option>
|
|
<option value="false">Inactive</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<ActionButton variant="secondary" onClick={() => setEditModal(false)}>Cancel</ActionButton>
|
|
<ActionButton loading={editMutation.isPending} onClick={() => editMutation.mutate({
|
|
id: editingAgent.id,
|
|
agentCode: editForm.agentCode,
|
|
commissionRate: parseInt(editForm.commissionRate) || 5,
|
|
active: editForm.active,
|
|
})}>Save Changes</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|