mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export default function PaymentMethodsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
const { setTheme } = useTheme();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Auth is already initialized in root providers
|
||||
// Just wait a tick for hydration
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, router, isLoading]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient, paymentsApi } from '@/lib/api';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
export default function PaymentMethodsPage() {
|
||||
const canManagePayments = usePermission(PERMS.payments.manage);
|
||||
const canManageAdmin = usePermission(PERMS.admin);
|
||||
const canManage = canManagePayments || canManageAdmin;
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [selectedMethod, setSelectedMethod] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'TELEBIRR',
|
||||
region: 'ETHIOPIA',
|
||||
currency: 'ETB',
|
||||
isEnabled: true,
|
||||
displayOrder: 1,
|
||||
description: '',
|
||||
fees: '',
|
||||
processingTime: ''
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: () => paymentsApi.getMethods(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => paymentsApi.addMethod(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payment-methods'] });
|
||||
setCreateModalOpen(false);
|
||||
resetForm();
|
||||
setSuccessMessage('Payment method added successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: any) => paymentsApi.updateMethod(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payment-methods'] });
|
||||
queryClient.refetchQueries({ queryKey: ['payment-methods'] });
|
||||
setEditModalOpen(false);
|
||||
setSelectedMethod(null);
|
||||
resetForm();
|
||||
setSuccessMessage('Payment method updated successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Update failed:', error);
|
||||
setSuccessMessage('Failed to update payment method');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => paymentsApi.deleteMethod(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payment-methods'] });
|
||||
setDeleteConfirmOpen(false);
|
||||
setSelectedMethod(null);
|
||||
setSuccessMessage('Payment method deleted successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'TELEBIRR',
|
||||
region: 'ETHIOPIA',
|
||||
currency: 'ETB',
|
||||
isEnabled: true,
|
||||
displayOrder: 1,
|
||||
description: '',
|
||||
fees: '',
|
||||
processingTime: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (method: any) => {
|
||||
setSelectedMethod(method);
|
||||
setFormData({
|
||||
name: method.displayName || method.name || '',
|
||||
type: method.type || 'TELEBIRR',
|
||||
region: method.region || 'ETHIOPIA',
|
||||
currency: method.currency || 'ETB',
|
||||
isEnabled: method.enabled ?? method.isEnabled ?? true,
|
||||
displayOrder: method.sortOrder ?? method.displayOrder ?? 1,
|
||||
description: method.description || '',
|
||||
fees: method.fees || '',
|
||||
processingTime: method.processingTime || ''
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (method: any) => {
|
||||
setSelectedMethod(method);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const submitData = {
|
||||
displayName: formData.name,
|
||||
type: formData.type,
|
||||
region: formData.region,
|
||||
currency: formData.currency,
|
||||
enabled: formData.isEnabled,
|
||||
sortOrder: formData.displayOrder,
|
||||
// Additional fields that might be expected
|
||||
description: formData.description,
|
||||
fees: formData.fees,
|
||||
processingTime: formData.processingTime,
|
||||
};
|
||||
|
||||
console.log('Submitting data:', submitData);
|
||||
|
||||
if (selectedMethod) {
|
||||
updateMutation.mutate({ id: selectedMethod.id, ...submitData });
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'displayName',
|
||||
label: 'Name',
|
||||
sortable: true,
|
||||
render: (method: any) => (
|
||||
<div>
|
||||
<div className="font-semibold">{method.displayName || method.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{method.type}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
label: 'Region',
|
||||
render: (method: any) => (
|
||||
<Badge>{method.region}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'currency',
|
||||
label: 'Currency',
|
||||
render: (method: any) => (
|
||||
<span className="font-mono text-sm">{method.currency}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Status',
|
||||
render: (method: any) => (
|
||||
<Badge variant="status" status={(method.enabled ?? method.isEnabled) ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{(method.enabled ?? method.isEnabled) ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
label: 'Order',
|
||||
render: (method: any) => (
|
||||
<span className="text-sm">{method.sortOrder ?? method.displayOrder}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: handleEdit,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit2,
|
||||
show: () => canManage,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
show: () => canManage,
|
||||
},
|
||||
];
|
||||
|
||||
const paymentTypes = [
|
||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
||||
{ value: 'EBIRR', label: 'eBirr' },
|
||||
{ value: 'WAAFI', label: 'Waafi' },
|
||||
{ value: 'CARD', label: 'Card Payment' },
|
||||
{ value: 'WALLET', label: 'Internal Wallet' },
|
||||
];
|
||||
|
||||
const regions = [
|
||||
{ value: 'ETHIOPIA', label: 'Ethiopia' },
|
||||
{ value: 'DJIBOUTI', label: 'Djibouti' },
|
||||
{ value: 'INTERNATIONAL', label: 'International' },
|
||||
];
|
||||
|
||||
const currencies = [
|
||||
{ value: 'ETB', label: 'Ethiopian Birr (ETB)' },
|
||||
{ value: 'DJF', label: 'Djiboutian Franc (DJF)' },
|
||||
{ value: 'USD', label: 'US Dollar (USD)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Payment Methods</h1>
|
||||
<p className="text-muted-foreground">Manage supported payment systems</p>
|
||||
</div>
|
||||
<PermissionGuard permission={PERMS.admin}>
|
||||
<ActionButton icon={Plus} onClick={() => setCreateModalOpen(true)}>
|
||||
Add Method
|
||||
</ActionButton>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
|
||||
✓ {successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
Error loading payment methods: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={data || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No payment methods found"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={createModalOpen || editModalOpen}
|
||||
onClose={() => {
|
||||
setCreateModalOpen(false);
|
||||
setEditModalOpen(false);
|
||||
setSelectedMethod(null);
|
||||
resetForm();
|
||||
}}
|
||||
title={selectedMethod ? 'Edit Payment Method' : 'Add Payment Method'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Telebirr Mobile Money"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Type *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
required
|
||||
>
|
||||
{paymentTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="label">Region *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
required
|
||||
>
|
||||
{regions.map((region) => (
|
||||
<option key={region.value} value={region.value}>{region.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Currency *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
required
|
||||
>
|
||||
{currencies.map((currency) => (
|
||||
<option key={currency.value} value={currency.value}>{currency.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={formData.displayOrder}
|
||||
onChange={(e) => setFormData({ ...formData, displayOrder: parseInt(e.target.value) || 1 })}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Brief description of the payment method..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Fees</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={formData.fees}
|
||||
onChange={(e) => setFormData({ ...formData, fees: e.target.value })}
|
||||
placeholder="e.g., 2.5% + 5 ETB"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Processing Time</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={formData.processingTime}
|
||||
onChange={(e) => setFormData({ ...formData, processingTime: e.target.value })}
|
||||
placeholder="e.g., Instant, 1-3 business days"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isEnabled}
|
||||
onChange={(e) => setFormData({ ...formData, isEnabled: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Enable this payment method</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setCreateModalOpen(false);
|
||||
setEditModalOpen(false);
|
||||
setSelectedMethod(null);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{selectedMethod ? 'Update' : 'Add'} Method
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setSelectedMethod(null);
|
||||
}}
|
||||
onConfirm={() => selectedMethod && deleteMutation.mutate(selectedMethod.id)}
|
||||
title="Delete Payment Method"
|
||||
message={`Are you sure you want to delete "${selectedMethod?.name}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
isLoading={deleteMutation.isPending}
|
||||
isDanger={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user