mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
feat: ( permissions ) enforce specific permission keys across API and backoffice
This commit is contained in:
@@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
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">
|
||||
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function AgentsPage() {
|
||||
function AgentsPageContent() {
|
||||
const { user } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.agents.create, PERMS.agents.manage);
|
||||
const canEdit = useWritePermission(PERMS.agents.edit, PERMS.agents.manage);
|
||||
const canDelete = useDeletePermission(PERMS.agents.delete);
|
||||
const [filters, setFilters] = useState({ search: '', active: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
@@ -131,6 +138,7 @@ export default function AgentsPage() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (agent: any) => openEditModal(agent),
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
@@ -143,6 +151,7 @@ export default function AgentsPage() {
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -156,7 +165,7 @@ export default function AgentsPage() {
|
||||
<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>
|
||||
<ActionButton icon={Plus} onClick={openCreateModal} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create agents'}>Add Agent</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -394,3 +403,11 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.agents.view}>
|
||||
<AgentsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { appReleasesApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
|
||||
|
||||
export default function AppReleasesPage() {
|
||||
function AppReleasesPageContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
@@ -184,3 +186,11 @@ export default function AppReleasesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppReleasesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.admin}>
|
||||
<AppReleasesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useAuthStore } from '@/lib/auth-store';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { useWritePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
// Add QR Scanner component
|
||||
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
|
||||
@@ -315,7 +318,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardingPage() {
|
||||
function BoardingPageContent() {
|
||||
const [qrInput, setQrInput] = useState('');
|
||||
const [lastScanned, setLastScanned] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -341,6 +344,8 @@ export default function BoardingPage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
|
||||
|
||||
const boardingMutation = useMutation({
|
||||
mutationFn: (qrCodeOrRef: string) =>
|
||||
ticketsApi.scanAndBoard(qrCodeOrRef, {
|
||||
@@ -482,7 +487,8 @@ export default function BoardingPage() {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
disabled={boardingMutation.isPending || !qrInput.trim()}
|
||||
disabled={boardingMutation.isPending || !qrInput.trim() || !canBoard}
|
||||
title={canBoard ? undefined : 'You do not have permission to board passengers'}
|
||||
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
|
||||
text-white font-semibold py-4 px-6 rounded-xl transition-colors
|
||||
disabled:cursor-not-allowed text-lg"
|
||||
@@ -605,4 +611,12 @@ export default function BoardingPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function BoardingPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.tickets.board}>
|
||||
<BoardingPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Pagination from '@/components/ui/Pagination';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { useDeletePermission, usePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { bookingsApi, apiClient } from '@/lib/api';
|
||||
@@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
);
|
||||
|
||||
function BookingsPageContent() {
|
||||
const canManage = usePermission(PERMS.bookings.manage);
|
||||
const canDeleteBooking = useDeletePermission(PERMS.bookings.delete);
|
||||
// Mirrors the API guard on POST /payments/:bookingId/force-confirm —
|
||||
// tickets:generate, with the usual super-admin / org-admin bypass.
|
||||
const canGenerateTicket = usePermission(PERMS.tickets.generate);
|
||||
@@ -302,7 +302,7 @@ function BookingsPageContent() {
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
|
||||
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
|
||||
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
|
||||
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDeleteBooking },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { seatClassesApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
function ClassesPageContent() {
|
||||
const [filters, setFilters] = useState({ search: '' });
|
||||
@@ -21,6 +22,10 @@ function ClassesPageContent() {
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.classes.create, PERMS.classes.manage);
|
||||
const canEdit = useWritePermission(PERMS.classes.edit, PERMS.classes.manage);
|
||||
const canDelete = useDeletePermission(PERMS.classes.delete);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['classes', filters],
|
||||
queryFn: () => seatClassesApi.getAll(),
|
||||
@@ -180,12 +185,14 @@ function ClassesPageContent() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (cls: any) => handleOpenModal(cls),
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
|
||||
@@ -12,6 +12,7 @@ import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
type Tab = 'types' | 'coaches';
|
||||
|
||||
@@ -155,6 +156,10 @@ function CoachesPageContent() {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.coaches.create, PERMS.coaches.manage);
|
||||
const canEdit = useWritePermission(PERMS.coaches.edit, PERMS.coaches.manage);
|
||||
const canDelete = useDeletePermission(PERMS.coaches.delete);
|
||||
|
||||
// Coach Types Queries
|
||||
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
|
||||
queryKey: ['coach-types'],
|
||||
@@ -438,6 +443,7 @@ function CoachesPageContent() {
|
||||
const coachTypeActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (item: any) => {
|
||||
setEditingItem({ ...item, isCoachType: true });
|
||||
setShowModal(true);
|
||||
@@ -447,6 +453,7 @@ function CoachesPageContent() {
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (item: any) => handleDelete(item, true),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -456,6 +463,7 @@ function CoachesPageContent() {
|
||||
const coachActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (item: any) => {
|
||||
setEditingItem({ ...item, isCoach: true });
|
||||
setSelectedCoachTypeId(item.coachTypeId || '');
|
||||
@@ -467,6 +475,7 @@ function CoachesPageContent() {
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (item: any) => handleDelete(item, false),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -489,6 +498,9 @@ function CoachesPageContent() {
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create coaches'}
|
||||
>
|
||||
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
|
||||
</ActionButton>
|
||||
|
||||
@@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
interface ExchangeRate {
|
||||
id: string;
|
||||
@@ -35,6 +37,10 @@ export default function CurrenciesPage() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.currencies.create, PERMS.currencies.manage);
|
||||
const canEdit = useWritePermission(PERMS.currencies.edit, PERMS.currencies.manage);
|
||||
const canDelete = useDeletePermission(PERMS.currencies.delete);
|
||||
|
||||
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
|
||||
queryKey: ['currencies'],
|
||||
queryFn: () => apiClient.get('/currencies'),
|
||||
@@ -124,12 +130,14 @@ export default function CurrenciesPage() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -143,7 +151,7 @@ export default function CurrenciesPage() {
|
||||
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>
|
||||
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create currency rates'}>
|
||||
Add Rate
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
|
||||
import { seatsApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useWritePermission } from '@/lib/use-permission';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -300,9 +303,10 @@ interface CoachCardProps {
|
||||
coach: CoachReport;
|
||||
schedule: ScheduleReport;
|
||||
onResolve: () => void;
|
||||
canResolve?: boolean;
|
||||
}
|
||||
|
||||
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
||||
function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDuplicates = coach.duplicates.length > 0;
|
||||
|
||||
@@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={onResolve}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
disabled={!canResolve}
|
||||
title={canResolve ? undefined : 'You do not have permission to resolve duplicate seats'}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
@@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DiscrepancyPage() {
|
||||
function DiscrepancyPageContent() {
|
||||
// POST /seats/duplicates/resolve is guarded by seats:edit (it deletes seat rows).
|
||||
const canResolveDuplicates = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
|
||||
const [date, setDate] = useState(today());
|
||||
const [searchDate, setSearchDate] = useState('');
|
||||
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
|
||||
@@ -488,6 +496,7 @@ export default function DiscrepancyPage() {
|
||||
coach={coach}
|
||||
schedule={schedule}
|
||||
onResolve={() => setResolveTarget({ schedule, coach })}
|
||||
canResolve={canResolveDuplicates}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -517,3 +526,11 @@ export default function DiscrepancyPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiscrepancyPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.seats.view}>
|
||||
<DiscrepancyPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import Modal from '@/components/ui/Modal';
|
||||
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useWritePermission } from '@/lib/use-permission';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
const STATUS_VARIANT: Record<string, any> = {
|
||||
PENDING: 'PENDING',
|
||||
@@ -19,8 +22,11 @@ const STATUS_VARIANT: Record<string, any> = {
|
||||
WAIVED: 'CANCELLED',
|
||||
};
|
||||
|
||||
export default function ExcessBaggagePage() {
|
||||
function ExcessBaggagePageContent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Logging or resending a luggage charge bills the passenger and sends a pay link.
|
||||
const canCharge = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage);
|
||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -173,7 +179,7 @@ export default function ExcessBaggagePage() {
|
||||
icon: Send,
|
||||
variant: 'secondary' as const,
|
||||
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
|
||||
show: (c: any) => c.status === 'PENDING',
|
||||
show: (c: any) => canCharge && c.status === 'PENDING',
|
||||
},
|
||||
{
|
||||
label: 'Waive',
|
||||
@@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
|
||||
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
disabled={!canCharge}
|
||||
title={canCharge ? undefined : 'You do not have permission to log a luggage charge'}
|
||||
onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}
|
||||
>
|
||||
Log Excess Luggage
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -421,3 +432,11 @@ export default function ExcessBaggagePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExcessBaggagePage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.bookings.view}>
|
||||
<ExcessBaggagePageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { fraudApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission } from '@/lib/use-permission';
|
||||
|
||||
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">
|
||||
@@ -30,11 +33,13 @@ const SEVERITY_GRAD: Record<string, string> = {
|
||||
LOW: 'from-blue-500 to-blue-600',
|
||||
};
|
||||
|
||||
export default function FraudDetectionPage() {
|
||||
function FraudDetectionPageContent() {
|
||||
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['fraud-alerts', filters],
|
||||
queryFn: () => fraudApi.getAlerts(filters),
|
||||
@@ -130,10 +135,11 @@ export default function FraudDetectionPage() {
|
||||
onClick: handleAcknowledge,
|
||||
variant: 'primary' as const,
|
||||
icon: CheckCircle,
|
||||
show: (alert: any) => !alert.acknowledged,
|
||||
show: (alert: any) => canManageFraud && !alert.acknowledged,
|
||||
},
|
||||
{
|
||||
label: 'Block User',
|
||||
show: () => canManageFraud,
|
||||
onClick: handleBlockUser,
|
||||
variant: 'danger' as const,
|
||||
icon: Ban,
|
||||
@@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FraudDetectionPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.fraud.view}>
|
||||
<FraudDetectionPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { notificationsApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
|
||||
|
||||
export default function NotificationsPage() {
|
||||
function NotificationsPageContent() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||
@@ -313,3 +315,11 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.notifications.send}>
|
||||
<NotificationsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { reportsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
export default function OperationalReportsPage() {
|
||||
function OperationalReportsPageContent() {
|
||||
const [filters, setFilters] = useState({ search: '', reportType: '' });
|
||||
const [selectedReport, setSelectedReport] = useState<any>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
@@ -465,3 +467,11 @@ export default function OperationalReportsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationalReportsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.catalog.view, PERMS.reports.view]}>
|
||||
<OperationalReportsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import Pagination from '@/components/ui/Pagination';
|
||||
import { packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
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">
|
||||
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function PackageBookingsPage() {
|
||||
function PackageBookingsPageContent() {
|
||||
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
|
||||
@@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageBookingsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.packages.view}>
|
||||
<PackageBookingsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { packageInquiriesApi, packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
|
||||
|
||||
@@ -19,12 +22,15 @@ const statusVariant: Record<string, string> = {
|
||||
CLOSED: 'default',
|
||||
};
|
||||
|
||||
export default function PackageInquiriesPage() {
|
||||
function PackageInquiriesPageContent() {
|
||||
const [filters, setFilters] = useState({ packageId: '', status: '' });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage);
|
||||
const canDelete = useDeletePermission(PERMS.inquiries.delete);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['package-inquiries', filters],
|
||||
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
|
||||
@@ -112,6 +118,8 @@ export default function PackageInquiriesPage() {
|
||||
<select
|
||||
className="input py-1 text-xs"
|
||||
value={row.status}
|
||||
disabled={!canEdit}
|
||||
title={canEdit ? undefined : 'You do not have permission to change an inquiry status'}
|
||||
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
@@ -125,6 +133,7 @@ export default function PackageInquiriesPage() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -191,3 +200,11 @@ export default function PackageInquiriesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageInquiriesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.inquiries.view}>
|
||||
<PackageInquiriesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import Modal from '@/components/ui/Modal';
|
||||
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
||||
// rejected instantly client-side instead of round-tripping to the server first.
|
||||
@@ -33,7 +36,7 @@ const emptyForm = {
|
||||
validFrom: '', validUntil: '',
|
||||
};
|
||||
|
||||
export default function PackagesPage() {
|
||||
function PackagesPageContent() {
|
||||
const [page] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
@@ -65,6 +68,11 @@ export default function PackagesPage() {
|
||||
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.packages.create, PERMS.packages.manage);
|
||||
const canEdit = useWritePermission(PERMS.packages.edit, PERMS.packages.manage);
|
||||
const canPublish = useWritePermission(PERMS.packages.publish, PERMS.packages.manage);
|
||||
const canDelete = useDeletePermission(PERMS.packages.delete);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['packages', page],
|
||||
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
|
||||
@@ -368,23 +376,25 @@ export default function PackagesPage() {
|
||||
|
||||
const actions = [
|
||||
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
|
||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
|
||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
|
||||
{
|
||||
label: 'Tiers', icon: Layers, variant: 'secondary' as const,
|
||||
show: () => canEdit,
|
||||
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
|
||||
},
|
||||
{
|
||||
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
|
||||
onClick: (p: any) => setActivateConfirm(p),
|
||||
show: (p: any) => p.status !== 'ACTIVE',
|
||||
show: (p: any) => canPublish && p.status !== 'ACTIVE',
|
||||
},
|
||||
{
|
||||
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
|
||||
onClick: (p: any) => setDeactivateConfirm(p),
|
||||
show: (p: any) => p.status === 'ACTIVE',
|
||||
show: (p: any) => canPublish && p.status === 'ACTIVE',
|
||||
},
|
||||
{
|
||||
label: 'Delete', icon: Trash2, variant: 'danger' as const,
|
||||
show: () => canDelete,
|
||||
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
|
||||
},
|
||||
];
|
||||
@@ -407,7 +417,7 @@ export default function PackagesPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Packages</h1>
|
||||
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
||||
<ActionButton icon={Plus} onClick={openCreate} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create packages'}>New Package</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* The package itself may already be saved and this modal closed by the time an image
|
||||
@@ -827,3 +837,11 @@ export default function PackagesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackagesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.packages.view}>
|
||||
<PackagesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { passengersApi, apiClient } from '@/lib/api';
|
||||
import { formatDate, formatDateTime } from '@/lib/utils';
|
||||
import { PassengerFilters } from '@/types';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
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">
|
||||
@@ -33,7 +36,7 @@ const TIER_COLORS: Record<string, string> = {
|
||||
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
|
||||
};
|
||||
|
||||
export default function PassengersPage() {
|
||||
function PassengersPageContent() {
|
||||
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
|
||||
@@ -51,6 +54,8 @@ export default function PassengersPage() {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canDelete = useDeletePermission(PERMS.passengers.delete);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
|
||||
onSuccess: () => {
|
||||
@@ -156,7 +161,7 @@ export default function PassengersPage() {
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
|
||||
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 },
|
||||
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -448,3 +453,11 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PassengersPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.passengers.view}>
|
||||
<PassengersPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient, paymentsApi } from '@/lib/api';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
export default function PaymentMethodsPage() {
|
||||
function PaymentMethodsPageContent() {
|
||||
const canManageMethods = usePermission(PERMS.paymentMethods.manage);
|
||||
const canManageAdmin = usePermission(PERMS.admin);
|
||||
const canManage = canManageMethods || canManageAdmin;
|
||||
@@ -417,4 +418,12 @@ export default function PaymentMethodsPage() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function PaymentMethodsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.paymentMethods.view}>
|
||||
<PaymentMethodsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import SupplementaryChargesModal from './SupplementaryChargesModal';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
import {
|
||||
useSupplementaryCharges,
|
||||
useMarkSupplementaryPaid,
|
||||
@@ -120,6 +123,10 @@ function PaymentsPageContent() {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Raising a supplementary charge bills a passenger and sends a pay link — its own grant.
|
||||
const canRaiseCharge = useWritePermission(PERMS.payments.supplementary, PERMS.payments.manage);
|
||||
const canDelete = useDeletePermission(PERMS.payments.delete);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
|
||||
onSuccess: () => {
|
||||
@@ -209,7 +216,7 @@ function PaymentsPageContent() {
|
||||
|
||||
const paymentActions = [
|
||||
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
|
||||
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
|
||||
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -221,7 +228,7 @@ function PaymentsPageContent() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{pageTab === 'supplementary' && (
|
||||
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton>
|
||||
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)} disabled={!canRaiseCharge} title={canRaiseCharge ? undefined : 'You do not have permission to raise a supplementary charge'}>Raise Charge</ActionButton>
|
||||
)}
|
||||
{pageTab === 'payments' && (
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
@@ -531,8 +538,10 @@ function PaymentsPageContent() {
|
||||
|
||||
export default function PaymentsPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PaymentsPageContent />
|
||||
</Suspense>
|
||||
<PermissionGuard permission={PERMS.payments.view}>
|
||||
<Suspense fallback={null}>
|
||||
<PaymentsPageContent />
|
||||
</Suspense>
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ import { usePagination } from "@/lib/use-pagination";
|
||||
import { formatCurrency, formatDateTime } from "@/lib/utils";
|
||||
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
|
||||
import { useTheme } from "@/lib/theme-store";
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
interface RouteOption {
|
||||
id: string;
|
||||
@@ -71,7 +73,7 @@ function reasonLabel(category: string | null): string {
|
||||
|
||||
const TABLE_PAGE_SIZE = 25;
|
||||
|
||||
export default function BlockedSeatRevenueLossPage() {
|
||||
function BlockedSeatRevenueLossPageContent() {
|
||||
const isDark = useTheme((s) => s.isDark);
|
||||
const palette = getChartPalette(isDark);
|
||||
|
||||
@@ -917,3 +919,11 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlockedSeatRevenueLossPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.blockedSeats.view, PERMS.reports.view]}>
|
||||
<BlockedSeatRevenueLossPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import ActionButton from "@/components/ui/ActionButton";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
@@ -50,7 +52,7 @@ interface BoardingReport {
|
||||
|
||||
type Tab = "summary" | "details";
|
||||
|
||||
export default function BoardingReportPage() {
|
||||
function BoardingReportPageContent() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("summary");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -380,3 +382,11 @@ export default function BoardingReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardingReportPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.boarding.view, PERMS.reports.view]}>
|
||||
<BoardingReportPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiClient } from "@/lib/api-client";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
@@ -28,7 +30,7 @@ interface CoachUtilizationRow {
|
||||
totalBookings: number;
|
||||
}
|
||||
|
||||
export default function CoachUtilizationReportPage() {
|
||||
function CoachUtilizationReportPageContent() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -360,3 +362,11 @@ export default function CoachUtilizationReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoachUtilizationReportPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.coachUtilization.view, PERMS.reports.view]}>
|
||||
<CoachUtilizationReportPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
interface StationOption {
|
||||
id: string;
|
||||
@@ -101,7 +103,7 @@ function FinanceReportSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinanceReportPage() {
|
||||
function FinanceReportPageContent() {
|
||||
const isDark = useTheme((s) => s.isDark);
|
||||
const palette = getChartPalette(isDark);
|
||||
|
||||
@@ -558,3 +560,11 @@ export default function FinanceReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinanceReportPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.finance.view, PERMS.reports.view]}>
|
||||
<FinanceReportPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
@@ -19,7 +21,7 @@ function esc(s: string) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
function ReportsPageContent() {
|
||||
const [dateRange, setDateRange] = useState('30');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
@@ -709,3 +711,11 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.overall.view, PERMS.reports.view]}>
|
||||
<ReportsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { formatDateTime } from "@/lib/utils";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
@@ -70,7 +72,7 @@ interface PassengerRow {
|
||||
|
||||
type Tab = "occupancy" | "list";
|
||||
|
||||
export default function PassengersReportPage() {
|
||||
function PassengersReportPageContent() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("occupancy");
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
@@ -592,3 +594,11 @@ export default function PassengersReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PassengersReportPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.passengers.view, PERMS.reports.view]}>
|
||||
<PassengersReportPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import DatePicker from '@/components/ui/DatePicker';
|
||||
import { parse, isValid } from 'date-fns';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -121,7 +123,7 @@ function BalanceBadge({ row }: { row: DiscrepancyRow }) {
|
||||
|
||||
type Applied = { from: string; to: string; sortBy: string; search: string };
|
||||
|
||||
export default function PaymentDiscrepancyPage() {
|
||||
function PaymentDiscrepancyPageContent() {
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
|
||||
@@ -479,3 +481,11 @@ export default function PaymentDiscrepancyPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentDiscrepancyPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
|
||||
<PaymentDiscrepancyPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,7 +103,7 @@ function downloadCsv(csv: string, filename: string) {
|
||||
|
||||
// ── Discrepancy page ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function PaymentsReportPage() {
|
||||
function PaymentsReportPageContent() {
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [seatClass, setSeatClass] = useState('');
|
||||
@@ -355,3 +357,11 @@ export default function PaymentsReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentsReportPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
|
||||
<PaymentsReportPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import ActionButton from "@/components/ui/ActionButton";
|
||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
@@ -56,7 +58,7 @@ interface SeatStatusReport {
|
||||
|
||||
type Tab = "seats" | "blocked";
|
||||
|
||||
export default function SeatStatusReportPage() {
|
||||
function SeatStatusReportPageContent() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("seats");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
||||
@@ -383,3 +385,11 @@ export default function SeatStatusReportPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeatStatusReportPage() {
|
||||
return (
|
||||
<PermissionGuard permission={[PERMS.reports.seatStatus.view, PERMS.reports.view]}>
|
||||
<SeatStatusReportPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
|
||||
|
||||
/**
|
||||
* Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no
|
||||
* policy cannot be rescheduled at all. Gated on bookings:view because that is what
|
||||
* `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side.
|
||||
* policy cannot be rescheduled at all. Gated on reschedule_policies:view (or :manage) — bookings:view no longer
|
||||
* grants it, so the page needs its own grant. Create/edit/delete each have their own key.
|
||||
*/
|
||||
export default function ReschedulePoliciesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.bookings.view}>
|
||||
<PermissionGuard permission={[PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage]}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
||||
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
interface RouteStop {
|
||||
stationId: string;
|
||||
@@ -193,6 +194,10 @@ function RoutesPageContent() {
|
||||
}, [error]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.routes.create, PERMS.routes.manage);
|
||||
const canEdit = useWritePermission(PERMS.routes.edit, PERMS.routes.manage);
|
||||
const canDelete = useDeletePermission(PERMS.routes.delete);
|
||||
|
||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: async () => {
|
||||
@@ -445,12 +450,14 @@ function RoutesPageContent() {
|
||||
const routeActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: openEditModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -479,6 +486,9 @@ function RoutesPageContent() {
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create routes'}
|
||||
>
|
||||
Add Route
|
||||
</ActionButton>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePagination } from '@/lib/use-pagination';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
||||
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
@@ -80,6 +81,13 @@ function SchedulesPageContent() {
|
||||
const errorBannerRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Mirrors the API guards exactly: create/edit/cancel accept the `:manage` umbrella,
|
||||
// delete does not (see PassengerWrite / PassengerDelete in the API).
|
||||
const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
|
||||
const canEdit = useWritePermission(PERMS.schedules.edit, PERMS.schedules.manage);
|
||||
const canCancel = useWritePermission(PERMS.schedules.cancel, PERMS.schedules.manage);
|
||||
const canDelete = useDeletePermission(PERMS.schedules.delete);
|
||||
|
||||
// These modals can scroll internally — a submit failure can land silently off-screen with no
|
||||
// visible indication anything went wrong. Scroll the banner into view when a new error appears.
|
||||
useEffect(() => {
|
||||
@@ -536,12 +544,16 @@ function SchedulesPageContent() {
|
||||
const [delayMinutesInput, setDelayMinutesInput] = useState('');
|
||||
const [delayError, setDelayError] = useState<string | null>(null);
|
||||
|
||||
// `hidden:` was silently doing nothing — DataTable's Action type only reads
|
||||
// `show` — so Report Delay and Cancel were rendering on already-cancelled
|
||||
// schedules. Folded into `show` alongside the permission check.
|
||||
const scheduleActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: handleEditClick,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
show: () => canEdit,
|
||||
},
|
||||
{
|
||||
label: 'Report Delay',
|
||||
@@ -552,20 +564,21 @@ function SchedulesPageContent() {
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Clock,
|
||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||
show: (schedule: Schedule) => canEdit && schedule.status !== 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||
variant: 'danger' as const,
|
||||
icon: X,
|
||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||
show: (schedule: Schedule) => canCancel && schedule.status !== 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
show: () => canDelete,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -582,6 +595,8 @@ function SchedulesPageContent() {
|
||||
onClick={handleBulkDelete}
|
||||
variant="danger"
|
||||
loading={bulkDeleteMutation.isPending}
|
||||
disabled={!canDelete}
|
||||
title={canDelete ? undefined : 'You do not have permission to delete schedules'}
|
||||
>
|
||||
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
|
||||
</ActionButton>
|
||||
@@ -590,6 +605,8 @@ function SchedulesPageContent() {
|
||||
icon={Plus}
|
||||
variant="secondary"
|
||||
onClick={() => { setError(null); setShowAddModal(true); }}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create schedules'}
|
||||
>
|
||||
Add Schedule
|
||||
</ActionButton>
|
||||
@@ -599,6 +616,8 @@ function SchedulesPageContent() {
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create schedules'}
|
||||
>
|
||||
Bulk Generate
|
||||
</ActionButton>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { usePermissionStrict } from '@/lib/use-permission';
|
||||
import { useDeletePermission, usePermissionStrict, useWritePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -63,6 +63,13 @@ function SeatsPageContent() {
|
||||
// Strict: being an admin is not enough, the permission has to be granted.
|
||||
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
||||
|
||||
// Blocking takes inventory out of sale, so it is its own grant; removing a seat is
|
||||
// the app's only soft delete and rides on `seats:delete`; maintenance is an edit.
|
||||
const canBlockSeats = useWritePermission(PERMS.seats.block, PERMS.seats.manage);
|
||||
const canEditSeats = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
|
||||
const canDeleteSeats = useDeletePermission(PERMS.seats.delete);
|
||||
const canCancelBooking = useWritePermission(PERMS.bookings.cancel, PERMS.bookings.manage);
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
queryKey: ['schedules'],
|
||||
queryFn: () => schedulesApi.getAll(),
|
||||
@@ -478,6 +485,10 @@ function SeatsPageContent() {
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
canIssueBooking={canIssueBooking}
|
||||
canBlockSeats={canBlockSeats}
|
||||
canEditSeats={canEditSeats}
|
||||
canDeleteSeats={canDeleteSeats}
|
||||
canCancelBooking={canCancelBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -576,6 +587,10 @@ function SeatsPageContent() {
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
canIssueBooking={canIssueBooking}
|
||||
canBlockSeats={canBlockSeats}
|
||||
canEditSeats={canEditSeats}
|
||||
canDeleteSeats={canDeleteSeats}
|
||||
canCancelBooking={canCancelBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -601,6 +616,10 @@ function SeatsPageContent() {
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
canIssueBooking={canIssueBooking}
|
||||
canBlockSeats={canBlockSeats}
|
||||
canEditSeats={canEditSeats}
|
||||
canDeleteSeats={canDeleteSeats}
|
||||
canCancelBooking={canCancelBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -846,7 +865,8 @@ function SeatsPageContent() {
|
||||
size="sm"
|
||||
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
|
||||
className="ml-2"
|
||||
disabled={!isCoachBlocked(coach) && !isCoachUnblocked(coach)}
|
||||
disabled={!canBlockSeats || (!isCoachBlocked(coach) && !isCoachUnblocked(coach))}
|
||||
title={canBlockSeats ? undefined : 'You do not have permission to block seats'}
|
||||
>
|
||||
{isCoachBlocked(coach) ? (
|
||||
<>
|
||||
@@ -1326,6 +1346,10 @@ interface SeatIconProps {
|
||||
handleClearMaintenance: (seat: any) => void;
|
||||
handleIssueBooking: (seat: any, coach: any) => void;
|
||||
canIssueBooking?: boolean;
|
||||
canBlockSeats?: boolean;
|
||||
canEditSeats?: boolean;
|
||||
canDeleteSeats?: boolean;
|
||||
canCancelBooking?: boolean;
|
||||
}
|
||||
|
||||
function SeatIcon({
|
||||
@@ -1345,6 +1369,10 @@ function SeatIcon({
|
||||
handleClearMaintenance,
|
||||
handleIssueBooking,
|
||||
canIssueBooking = false,
|
||||
canBlockSeats = false,
|
||||
canEditSeats = false,
|
||||
canDeleteSeats = false,
|
||||
canCancelBooking = false,
|
||||
}: SeatIconProps) {
|
||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||
@@ -1362,13 +1390,15 @@ function SeatIcon({
|
||||
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
|
||||
</div>
|
||||
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||
<button
|
||||
onClick={() => handleUndoRemove(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Undo remove"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
{canEditSeats && (
|
||||
<button
|
||||
onClick={() => handleUndoRemove(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Undo remove"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1376,14 +1406,14 @@ function SeatIcon({
|
||||
|
||||
const status = getSeatStatus(seat);
|
||||
const color = getSeatColor(status);
|
||||
const canBlock = status === 'AVAILABLE';
|
||||
const canUnblock = status === 'BLOCKED';
|
||||
const canBlock = canBlockSeats && status === 'AVAILABLE';
|
||||
const canUnblock = canBlockSeats && status === 'BLOCKED';
|
||||
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
|
||||
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
|
||||
// it's not reachable via canUnblock anymore; this is the seat's own release path.
|
||||
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
||||
const canCancelReservation = canCancelBooking && status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
||||
const canMaintenance = false;
|
||||
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
||||
const canClearMaintenance = canEditSeats && status === 'UNDER_MAINTENANCE';
|
||||
|
||||
return (
|
||||
<div className="relative group flex flex-col items-center">
|
||||
@@ -1431,13 +1461,15 @@ function SeatIcon({
|
||||
>
|
||||
<Lock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveSeat(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Remove seat"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
{canDeleteSeats && (
|
||||
<button
|
||||
onClick={() => handleRemoveSeat(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Remove seat"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canCancelReservation && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import Pagination from '@/components/ui/Pagination';
|
||||
import { usePagination } from '@/lib/use-pagination';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
function StationsPageContent() {
|
||||
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
||||
@@ -22,6 +23,10 @@ function StationsPageContent() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.stations.create, PERMS.stations.manage);
|
||||
const canEdit = useWritePermission(PERMS.stations.edit, PERMS.stations.manage);
|
||||
const canDelete = useDeletePermission(PERMS.stations.delete);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['stations', filters],
|
||||
queryFn: () => stationsApi.getAll(filters),
|
||||
@@ -164,6 +169,7 @@ function StationsPageContent() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (station: any) => {
|
||||
setEditingStation(station);
|
||||
setFormError(null);
|
||||
@@ -174,6 +180,7 @@ function StationsPageContent() {
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -194,6 +201,8 @@ function StationsPageContent() {
|
||||
setFormError(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create stations'}
|
||||
>
|
||||
Add Station
|
||||
</ActionButton>
|
||||
|
||||
@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
|
||||
import type { Route, RouteFareRule, SeatClass } from './types';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
interface Props {
|
||||
routes: Route[];
|
||||
@@ -21,6 +23,12 @@ type OverrideForm = {
|
||||
};
|
||||
|
||||
export default function OverridesTab({ routes }: Props) {
|
||||
// Fare rules are their own resource on the API (schedule_fares:*), split out of
|
||||
// schedules:manage so editing a timetable and changing a price are separate grants.
|
||||
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
|
||||
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
|
||||
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
|
||||
|
||||
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -148,6 +156,9 @@ export default function OverridesTab({ routes }: Props) {
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
|
||||
|
||||
disabled={!canCreateFare}
|
||||
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
|
||||
>
|
||||
Add Override
|
||||
</ActionButton>
|
||||
@@ -165,8 +176,9 @@ export default function OverridesTab({ routes }: Props) {
|
||||
{
|
||||
label: 'Edit', icon: Edit, variant: 'secondary' as const,
|
||||
onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }),
|
||||
show: () => canEditFare,
|
||||
},
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDeleteFare },
|
||||
]}
|
||||
loading={isLoading}
|
||||
emptyMessage="No overrides for this route. Click 'Add Override' to create one."
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useSegmentFareRules, useSegmentFareMutations, useSeatClasses, useRoutes
|
||||
import type { Route, SegmentFareRule, SeatClass } from './types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
interface RouteStop { sequence: number; station?: { name: string; code: string } }
|
||||
|
||||
@@ -50,6 +52,12 @@ interface Props { routes: Route[] }
|
||||
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
|
||||
|
||||
export default function SegmentOverridesTab({ routes }: Props) {
|
||||
// Fare rules are their own resource on the API (schedule_fares:*), split out of
|
||||
// schedules:manage so editing a timetable and changing a price are separate grants.
|
||||
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
|
||||
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
|
||||
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
|
||||
|
||||
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
|
||||
@@ -164,7 +172,12 @@ export default function SegmentOverridesTab({ routes }: Props) {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ActionButton icon={Plus} onClick={() => setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
|
||||
disabled={!selectedRouteId || !canCreateFare}
|
||||
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
|
||||
>
|
||||
Add Segment Override
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) {
|
||||
data={segmentFares}
|
||||
columns={columns}
|
||||
actions={[
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) },
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }) },
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }), show: () => canEditFare },
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }), show: () => canDeleteFare },
|
||||
]}
|
||||
loading={isLoading}
|
||||
emptyMessage="No segment overrides for this route."
|
||||
|
||||
@@ -14,12 +14,14 @@ interface Props {
|
||||
isLoading: boolean;
|
||||
onEdit: (cls: SeatClass) => void;
|
||||
onDelete: (id: string, cascade: boolean) => void;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
isDeleting: boolean;
|
||||
deleteError?: string;
|
||||
deleteSuccess?: number;
|
||||
}
|
||||
|
||||
export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) {
|
||||
export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess, canEdit = false, canDelete = false }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -141,8 +143,8 @@ export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDe
|
||||
data={displayed}
|
||||
columns={columns}
|
||||
actions={[
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit },
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit, show: () => canEdit },
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDelete },
|
||||
]}
|
||||
loading={isLoading}
|
||||
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
|
||||
|
||||
@@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab';
|
||||
import RateModal from './RateModal';
|
||||
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
|
||||
import type { SeatClass, TabType } from './types';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
export default function TariffRatesPage() {
|
||||
function TariffRatesPageContent() {
|
||||
const [tab, setTab] = useState<TabType>('tariff');
|
||||
const [showRateModal, setShowRateModal] = useState(false);
|
||||
const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
|
||||
@@ -20,6 +23,10 @@ export default function TariffRatesPage() {
|
||||
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
|
||||
const [deleteSuccess, setDeleteSuccess] = useState(0);
|
||||
|
||||
const canCreateRate = useWritePermission(PERMS.tariffRates.create, PERMS.tariffRates.manage);
|
||||
const canEditRate = useWritePermission(PERMS.tariffRates.edit, PERMS.tariffRates.manage);
|
||||
const canDeleteRate = useDeletePermission(PERMS.tariffRates.delete);
|
||||
|
||||
const { allClasses, isLoading } = useSeatClasses();
|
||||
const { coachTypes } = useCoachTypes();
|
||||
const { routes } = useRoutes();
|
||||
@@ -71,7 +78,11 @@ export default function TariffRatesPage() {
|
||||
</p>
|
||||
</div>
|
||||
{tab !== 'overrides' && tab !== 'segment-overrides' && (
|
||||
<ActionButton icon={Plus} onClick={() => {
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
disabled={!canCreateRate}
|
||||
title={canCreateRate ? undefined : 'You do not have permission to create tariff rates'}
|
||||
onClick={() => {
|
||||
if (tab === 'baggage') {
|
||||
setShowBaggageModal(true);
|
||||
} else {
|
||||
@@ -105,6 +116,8 @@ export default function TariffRatesPage() {
|
||||
isLoading={isLoading}
|
||||
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
|
||||
onDelete={handleDelete}
|
||||
canEdit={canEditRate}
|
||||
canDelete={canDeleteRate}
|
||||
isDeleting={seatClassMutations.remove.isPending}
|
||||
deleteError={deleteError}
|
||||
deleteSuccess={deleteSuccess}
|
||||
@@ -143,3 +156,11 @@ export default function TariffRatesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TariffRatesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.tariffRates.view}>
|
||||
<TariffRatesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } fro
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { usePermission, useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
export default function TicketsPage() {
|
||||
function TicketsPageContent() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
||||
const [ticketPage, setTicketPage] = useState(1);
|
||||
const resetTicketPage = () => setTicketPage(1);
|
||||
@@ -69,6 +72,13 @@ export default function TicketsPage() {
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canGenerate = usePermission(PERMS.tickets.generate);
|
||||
const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage);
|
||||
const canDelete = useDeletePermission(PERMS.tickets.delete);
|
||||
// Logging luggage bills the passenger and sends a pay link, so it is its own grant.
|
||||
const canLogBaggage = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage);
|
||||
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tickets', filters, ticketPage],
|
||||
queryFn: () => ticketsApi.getAll({
|
||||
@@ -554,7 +564,7 @@ export default function TicketsPage() {
|
||||
onClick: openExcessModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Package,
|
||||
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||
show: (ticket: any) => canLogBaggage && !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
@@ -562,6 +572,7 @@ export default function TicketsPage() {
|
||||
variant: 'primary' as const,
|
||||
icon: LogIn,
|
||||
show: (ticket: any) => {
|
||||
if (!canBoard) return false;
|
||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isRoundTrip) {
|
||||
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
|
||||
@@ -595,10 +606,11 @@ export default function TicketsPage() {
|
||||
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
|
||||
variant: 'secondary' as const,
|
||||
icon: ListCollapse,
|
||||
show: (ticket: any) => ticket.status === 'CANCELLED',
|
||||
show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: handleDeleteClick,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -621,6 +633,8 @@ export default function TicketsPage() {
|
||||
variant="secondary"
|
||||
loading={generateMissingMutation.isPending}
|
||||
onClick={() => generateMissingMutation.mutate()}
|
||||
disabled={!canGenerate}
|
||||
title={canGenerate ? undefined : 'You do not have permission to generate tickets'}
|
||||
>
|
||||
Generate Missing
|
||||
</ActionButton>
|
||||
@@ -760,7 +774,13 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
|
||||
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton>
|
||||
<ActionButton
|
||||
icon={LogIn}
|
||||
loading={boardMutation.isPending}
|
||||
onClick={handleConfirmBoard}
|
||||
disabled={!canBoard}
|
||||
title={canBoard ? undefined : 'You do not have permission to board passengers'}
|
||||
>Board and Print</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -1103,3 +1123,11 @@ export default function TicketsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TicketsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.tickets.view}>
|
||||
<TicketsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Train as TrainType } from '@/types';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
|
||||
function TrainsPageContent() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -24,6 +25,10 @@ function TrainsPageContent() {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canCreate = useWritePermission(PERMS.trains.create, PERMS.trains.manage);
|
||||
const canEdit = useWritePermission(PERMS.trains.edit, PERMS.trains.manage);
|
||||
const canDelete = useDeletePermission(PERMS.trains.delete);
|
||||
|
||||
const { data: trainsData, isLoading: trainsLoading } = useQuery({
|
||||
queryKey: ['trains'],
|
||||
queryFn: () => fleetApi.getTrains(),
|
||||
@@ -174,6 +179,7 @@ function TrainsPageContent() {
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
show: () => canEdit,
|
||||
onClick: (train: TrainType) => {
|
||||
setEditingTrain(train);
|
||||
setShowModal(true);
|
||||
@@ -186,10 +192,11 @@ function TrainsPageContent() {
|
||||
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
|
||||
variant: 'secondary' as const,
|
||||
icon: RotateCcw,
|
||||
show: (train: TrainType) => !train.isActive,
|
||||
show: (train: TrainType) => canEdit && !train.isActive,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -209,6 +216,8 @@ function TrainsPageContent() {
|
||||
setShowModal(true);
|
||||
}}
|
||||
icon={Plus}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create trains'}
|
||||
>
|
||||
Add Train
|
||||
</ActionButton>
|
||||
|
||||
@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
|
||||
|
||||
/**
|
||||
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
|
||||
* can be neither upgraded from nor to. Gated on bookings:view because that is what
|
||||
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side.
|
||||
* can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer
|
||||
* grants it, so the page needs its own grant. Create/edit/delete each have their own key.
|
||||
*/
|
||||
export default function UpgradePoliciesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.bookings.view}>
|
||||
<PermissionGuard permission={[PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage]}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
|
||||
|
||||
@@ -2,17 +2,28 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
interface Props {
|
||||
permission?: string;
|
||||
/**
|
||||
* A single key, or several of which the user needs **any one**. The any-of form
|
||||
* is how a report page accepts either its own key or the `reports:view`
|
||||
* umbrella — mirroring the OR semantics of the API's `PassengerPermissionGuard`.
|
||||
*/
|
||||
permission?: string | string[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a page to enforce auth + optional permission check.
|
||||
* - Not logged in → redirect to /login
|
||||
* - Missing permission → redirect to /dashboard
|
||||
* - Missing permission → render an explanation (see below)
|
||||
*
|
||||
* This used to redirect a user without the permission to /dashboard. That is a
|
||||
* dead end for anyone lacking `dashboard:view`, since that page renders nothing
|
||||
* either — they got a blank screen with no explanation. Saying what happened is
|
||||
* both kinder and easier to support.
|
||||
*/
|
||||
export function PermissionGuard({ permission, children }: Props) {
|
||||
const router = useRouter();
|
||||
@@ -20,17 +31,26 @@ export function PermissionGuard({ permission, children }: Props) {
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (permission && !hasPermission(permission)) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, permission, hasPermission, router]);
|
||||
if (!isAuthenticated) router.replace('/login');
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (permission && !hasPermission(permission)) return null;
|
||||
|
||||
const keys = permission === undefined ? [] : Array.isArray(permission) ? permission : [permission];
|
||||
const allowed = keys.length === 0 || keys.some((key) => hasPermission(key));
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<ShieldOff className="h-10 w-10 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">You don't have access to this page</h2>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
Your account is missing the permission this page requires. Ask an administrator to grant
|
||||
it if you need access.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
permission?: string;
|
||||
/** A single key, or several of which the user needs any one. */
|
||||
permission?: string | string[];
|
||||
}
|
||||
|
||||
const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
@@ -92,8 +93,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
||||
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
|
||||
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view },
|
||||
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: [PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage] },
|
||||
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: [PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage] },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -128,16 +129,16 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'Analytics & Reports',
|
||||
items: [
|
||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
|
||||
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
|
||||
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
|
||||
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
|
||||
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: [PERMS.reports.overall.view, PERMS.reports.view] },
|
||||
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: [PERMS.reports.finance.view, PERMS.reports.view] },
|
||||
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: [PERMS.reports.coachUtilization.view, PERMS.reports.view] },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: [PERMS.reports.seatStatus.view, PERMS.reports.view] },
|
||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: [PERMS.reports.blockedSeats.view, PERMS.reports.view] },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: [PERMS.reports.passengers.view, PERMS.reports.view] },
|
||||
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: [PERMS.reports.boarding.view, PERMS.reports.view] },
|
||||
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
|
||||
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
|
||||
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: [PERMS.reports.catalog.view, PERMS.reports.view] },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -217,9 +218,11 @@ export default function Sidebar() {
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
||||
{navigationSections.map((section) => {
|
||||
const visibleItems = section.items.filter(
|
||||
(item) => !item.permission || hasPermission(item.permission)
|
||||
);
|
||||
const visibleItems = section.items.filter((item) => {
|
||||
if (!item.permission) return true;
|
||||
const keys = Array.isArray(item.permission) ? item.permission : [item.permission];
|
||||
return keys.some((key) => hasPermission(key));
|
||||
});
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<div key={section.title}>
|
||||
|
||||
@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import {
|
||||
reschedulePolicyApi,
|
||||
type ReschedulePolicyCoachType,
|
||||
@@ -35,6 +37,10 @@ const feeLabel = (percent: number, minMinor: number) =>
|
||||
* the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
|
||||
*/
|
||||
export default function ReschedulePolicyManager() {
|
||||
const canCreate = useWritePermission(PERMS.reschedulePolicies.create, PERMS.reschedulePolicies.manage);
|
||||
const canEdit = useWritePermission(PERMS.reschedulePolicies.edit, PERMS.reschedulePolicies.manage);
|
||||
const canDelete = useDeletePermission(PERMS.reschedulePolicies.delete);
|
||||
|
||||
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
|
||||
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -188,9 +194,10 @@ export default function ReschedulePolicyManager() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
|
||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (row: ReschedulePolicyRow) => setDeleting(row),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -206,7 +213,12 @@ export default function ReschedulePolicyManager() {
|
||||
not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
|
||||
policy here cannot be rescheduled at all.
|
||||
</p>
|
||||
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={openCreate}
|
||||
disabled={available.length === 0 || !canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create reschedule policies'}
|
||||
>
|
||||
Add Reschedule Policy
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -353,7 +365,13 @@ export default function ReschedulePolicyManager() {
|
||||
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton icon={Save} onClick={submit} loading={saving}>
|
||||
<ActionButton
|
||||
icon={Save}
|
||||
onClick={submit}
|
||||
loading={saving}
|
||||
disabled={editing ? !canEdit : !canCreate}
|
||||
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change reschedule policies'}
|
||||
>
|
||||
{editing ? 'Update Policy' : 'Create Policy'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,12 @@ interface ActionButtonProps {
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
/**
|
||||
* Native tooltip. CLAUDE.md asks for a disabled control with a visible reason
|
||||
* over a silently hidden one, so permission-gated buttons pass the reason here
|
||||
* alongside `disabled`.
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
@@ -40,6 +46,7 @@ export default function ActionButton({
|
||||
loading = false,
|
||||
className,
|
||||
type = 'button',
|
||||
title,
|
||||
}: ActionButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -63,6 +70,7 @@ export default function ActionButton({
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',
|
||||
|
||||
@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import {
|
||||
upgradePolicyApi,
|
||||
type UpgradePolicyCoachType,
|
||||
@@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) =>
|
||||
* a dialog, the same shape as Reschedule Policies and Coach Management.
|
||||
*/
|
||||
export default function UpgradePolicyManager() {
|
||||
const canCreate = useWritePermission(PERMS.upgradePolicies.create, PERMS.upgradePolicies.manage);
|
||||
const canEdit = useWritePermission(PERMS.upgradePolicies.edit, PERMS.upgradePolicies.manage);
|
||||
const canDelete = useDeletePermission(PERMS.upgradePolicies.delete);
|
||||
|
||||
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
|
||||
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -186,9 +192,10 @@ export default function UpgradePolicyManager() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
|
||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
|
||||
{
|
||||
label: 'Delete',
|
||||
show: () => canDelete,
|
||||
onClick: (row: UpgradePolicyRow) => setDeleting(row),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
@@ -204,7 +211,12 @@ export default function UpgradePolicyManager() {
|
||||
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
|
||||
upgraded from nor to.
|
||||
</p>
|
||||
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={openCreate}
|
||||
disabled={available.length === 0 || !canCreate}
|
||||
title={canCreate ? undefined : 'You do not have permission to create upgrade policies'}
|
||||
>
|
||||
Add Upgrade Policy
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() {
|
||||
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton icon={Save} onClick={submit} loading={saving}>
|
||||
<ActionButton
|
||||
icon={Save}
|
||||
onClick={submit}
|
||||
loading={saving}
|
||||
disabled={editing ? !canEdit : !canCreate}
|
||||
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change upgrade policies'}
|
||||
>
|
||||
{editing ? 'Update Policy' : 'Create Policy'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
||||
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
|
||||
const FORBIDDEN_MESSAGE =
|
||||
'You do not have permission to do that. Ask an administrator if you need access.';
|
||||
|
||||
/**
|
||||
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
|
||||
@@ -55,6 +57,21 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// A 403 from this API is always a permission check, and the server's own text
|
||||
// names raw permission keys ("Missing permission. Required one of: …") which
|
||||
// means nothing to a user. Replace it with something actionable, but only when
|
||||
// the server did not send a more specific message of its own.
|
||||
if (error.response?.status === 403) {
|
||||
const body = error.response.data;
|
||||
const serverMessage = typeof body?.message === 'string' ? body.message : '';
|
||||
if (!serverMessage || serverMessage.startsWith('Missing permission')) {
|
||||
const friendly = FORBIDDEN_MESSAGE;
|
||||
if (body && typeof body === 'object') body.message = friendly;
|
||||
error.message = friendly;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
|
||||
// '<fallback>'` call site across the app picks up a friendly message automatically,
|
||||
// instead of raw axios/network text or an unjoined NestJS validation array.
|
||||
|
||||
@@ -9,6 +9,20 @@ export const PERMS = {
|
||||
edit: 'edr_passenger_app:bookings:edit',
|
||||
delete: 'edr_passenger_app:bookings:delete',
|
||||
},
|
||||
reschedulePolicies: {
|
||||
view: 'edr_passenger_app:reschedule_policies:view',
|
||||
manage: 'edr_passenger_app:reschedule_policies:manage',
|
||||
create: 'edr_passenger_app:reschedule_policies:create',
|
||||
edit: 'edr_passenger_app:reschedule_policies:edit',
|
||||
delete: 'edr_passenger_app:reschedule_policies:delete',
|
||||
},
|
||||
upgradePolicies: {
|
||||
view: 'edr_passenger_app:upgrade_policies:view',
|
||||
manage: 'edr_passenger_app:upgrade_policies:manage',
|
||||
create: 'edr_passenger_app:upgrade_policies:create',
|
||||
edit: 'edr_passenger_app:upgrade_policies:edit',
|
||||
delete: 'edr_passenger_app:upgrade_policies:delete',
|
||||
},
|
||||
passengers: {
|
||||
view: 'edr_passenger_app:passengers:view',
|
||||
manage: 'edr_passenger_app:passengers:manage',
|
||||
@@ -20,6 +34,7 @@ export const PERMS = {
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
generate: 'edr_passenger_app:tickets:generate',
|
||||
board: 'edr_passenger_app:tickets:board',
|
||||
create: 'edr_passenger_app:tickets:create',
|
||||
edit: 'edr_passenger_app:tickets:edit',
|
||||
delete: 'edr_passenger_app:tickets:delete',
|
||||
@@ -114,6 +129,7 @@ export const PERMS = {
|
||||
view: 'edr_passenger_app:payments:view',
|
||||
manage: 'edr_passenger_app:payments:manage',
|
||||
create: 'edr_passenger_app:payments:create',
|
||||
supplementary: 'edr_passenger_app:payments:supplementary',
|
||||
edit: 'edr_passenger_app:payments:edit',
|
||||
delete: 'edr_passenger_app:payments:delete',
|
||||
// legacy aliases — still honoured by the backend guards
|
||||
@@ -121,6 +137,9 @@ export const PERMS = {
|
||||
refund: 'edr_passenger_app:payments:refund',
|
||||
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
||||
},
|
||||
excessBaggage: {
|
||||
charge: 'edr_passenger_app:excess_baggage:charge',
|
||||
},
|
||||
paymentMethods: {
|
||||
view: 'edr_passenger_app:payment_methods:view',
|
||||
manage: 'edr_passenger_app:payment_methods:manage',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from './auth-store';
|
||||
import { PERMS } from './permissions';
|
||||
|
||||
/**
|
||||
* Returns whether the current user has a given permission key.
|
||||
@@ -14,6 +15,20 @@ export function usePermission(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermission(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user holds **any one** of the keys — the same OR semantics as the
|
||||
* API's `PassengerPermissionGuard`.
|
||||
*
|
||||
* Use it wherever a route accepts a narrow key or a broader one, so the UI agrees
|
||||
* with the API instead of hiding a control the server would have allowed:
|
||||
*
|
||||
* const canCreate = useAnyPermission([PERMS.schedules.create, PERMS.schedules.manage]);
|
||||
* const canSeeFinance = useAnyPermission([PERMS.reports.finance.view, PERMS.reports.view]);
|
||||
*/
|
||||
export function useAnyPermission(keys: string[]): boolean {
|
||||
return useAuthStore((s) => keys.some((key) => s.hasPermission(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
|
||||
* permission must be explicitly granted. Use it wherever the API endpoint is
|
||||
@@ -25,3 +40,23 @@ export function usePermission(key: string): boolean {
|
||||
export function usePermissionStrict(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermissionStrict(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* The UI mirror of the API's `@PassengerWrite(narrow, umbrella)`: the narrow key,
|
||||
* the resource's `:manage` umbrella, or `admin`. Use it for create / edit / domain
|
||||
* actions so a control is shown exactly when the server would accept the call.
|
||||
*
|
||||
* const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
|
||||
*/
|
||||
export function useWritePermission(narrow: string, umbrella: string): boolean {
|
||||
return useAnyPermission([narrow, umbrella, PERMS.admin]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The UI mirror of the API's `@PassengerDelete(narrow)`: the narrow `:delete` key
|
||||
* or `admin`. **`:manage` deliberately does not count** — holding `schedules:manage`
|
||||
* does not let you delete a schedule, so the button must not appear either.
|
||||
*/
|
||||
export function useDeletePermission(narrow: string): boolean {
|
||||
return useAnyPermission([narrow, PERMS.admin]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user