feat(backoffice): guard routes and actions by permission

Mirrors the new keys in lib/permissions.ts, wraps the warehouse,
overview, reports, support and booking-request routes in
RequirePermission, and gates the dispatch, mark-paid, invoice pay/cancel,
export and support-send actions behind their own keys. Removes duplicate
route blocks.
This commit is contained in:
Nathnael
2026-08-07 07:31:31 +00:00
parent 0114673120
commit c1fcabcba2
10 changed files with 153 additions and 237 deletions

View File

@@ -164,7 +164,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Reports", label: "Reports",
href: "/dashboard/reports", href: "/dashboard/reports",
icon: <BarChart3 />, icon: <BarChart3 />,
permission: FREIGHT_PERMS.bookings.view, permission: FREIGHT_PERMS.reports.view,
}, },
{ {
label: "Customers", label: "Customers",
@@ -196,19 +196,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Payments", label: "Payments",
href: "/dashboard/payments", href: "/dashboard/payments",
icon: <Wallet />, icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view, permission: FREIGHT_PERMS.payments.view,
}, },
{ {
label: "Invoices", label: "Invoices",
href: "/dashboard/invoices", href: "/dashboard/invoices",
icon: <Receipt />, icon: <Receipt />,
permission: FREIGHT_PERMS.bookings.view, permission: FREIGHT_PERMS.invoices.view,
}, },
{ {
label: "Support", label: "Support",
href: "/dashboard/support", href: "/dashboard/support",
icon: <LifeBuoy />, icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.view, permission: FREIGHT_PERMS.support.agentView,
}, },
...demoItems, ...demoItems,
], ],
@@ -844,26 +844,30 @@ const App = () => {
element={<Navigate to="/dashboard/overview" replace />} element={<Navigate to="/dashboard/overview" replace />}
/> />
<Route path="/dashboard" element={<DashboardShell />}> <Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} /> <Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<ReportsHubPage />} /> <Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<ReportPage />} /> <Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
{/* Dev/testing page for the mock AI booking assistant. */} {/* Dev/testing page for the mock AI booking assistant. */}
<Route <Route
path="ai-booking-mock-test" path="ai-booking-mock-test"
element={<AiBookingMockTestPage />} element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<AiBookingMockTestPage />
</RequirePermission>
}
/> />
<Route path="profile" element={<MyProfilePage />} /> <Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} /> <Route path="booking-requests" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><BookingRequestsPage /></RequirePermission>} />
<Route <Route
path="payments" path="payments"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}> <RequirePermission permission={FREIGHT_PERMS.payments.view}>
<PaymentsPage /> <PaymentsPage />
</RequirePermission> </RequirePermission>
} }
/> />
<Route path="support" element={<SupportInboxPage />} /> <Route path="support" element={<RequirePermission permission={FREIGHT_PERMS.support.agentView}><SupportInboxPage /></RequirePermission>} />
<Route <Route
path="customers" path="customers"
element={ element={
@@ -883,7 +887,7 @@ const App = () => {
<Route <Route
path="invoices" path="invoices"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}> <RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage /> <InvoicesPage />
</RequirePermission> </RequirePermission>
} }
@@ -891,19 +895,27 @@ const App = () => {
<Route <Route
path="invoices/:id" path="invoices/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}> <RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoiceDetailPage /> <InvoiceDetailPage />
</RequirePermission> </RequirePermission>
} }
/> />
<Route path="booking-requests/new" element={<NewBookingPage />} /> <Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route <Route
path="booking-requests/:id" path="booking-requests/:id"
element={<BookingRequestDetailPage />} element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingRequestDetailPage />
</RequirePermission>
}
/> />
<Route <Route
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingContractPage />
</RequirePermission>
}
/> />
{/* Legacy booking-based clearance URLs → the contract clearance hub. */} {/* Legacy booking-based clearance URLs → the contract clearance hub. */}
<Route <Route
@@ -1091,44 +1103,26 @@ const App = () => {
path="bookings/:id/milestones" path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />} element={<BookingMilestonesRedirect />}
/> />
<Route path="warehouses" element={<WarehouseListPage />} /> <Route path="warehouses" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseListPage /></RequirePermission>} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} /> <Route path="warehouses/:id" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseDetailPage /></RequirePermission>} />
<Route <Route path="warehouse-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><WarehouseInventoryPage /></RequirePermission>} />
path="warehouse-inventory" <Route path="import-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportWarehouseFlowPage /></RequirePermission>} />
element={<WarehouseInventoryPage />} <Route path="export-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportWarehouseFlowPage /></RequirePermission>} />
/> <Route path="arrival-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ArrivalQueuePage /></RequirePermission>} />
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} /> <Route path="loading-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadingQueuePage /></RequirePermission>} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} /> <Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} /> <Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
<Route path="loading-queue" element={<LoadingQueuePage />} /> <Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
<Route path="intercity" element={<IntercityPage />} /> <Route path="edr-last-mile-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><EDRLastMileReturnsPage /></RequirePermission>} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} /> <Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
<Route path="import-trucks" element={<ImportTrucksPage />} /> <Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
<Route <Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
path="edr-last-mile-returns" <Route path="export-djibouti-unloading" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportDjiboutiUnloadingQueuePage /></RequirePermission>} />
element={<EDRLastMileReturnsPage />} <Route path="interchange-documents" element={<RequirePermission permission={FREIGHT_PERMS.interchangeDocuments.view}><InterchangeDocumentsPage /></RequirePermission>} />
/> <Route path="inventory-inquiry" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><InventoryInquiryPage /></RequirePermission>} />
<Route path="container-returns" element={<ContainerReturnsPage />} /> <Route path="warehouse-rules" element={<RequirePermission permission={[FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view]}><WarehouseRulesPage /></RequirePermission>} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} /> <Route path="warehouse-fee-invoices" element={<RequirePermission permission={FREIGHT_PERMS.warehouseFeeInvoices.view}><WarehouseInvoicesPage /></RequirePermission>} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} /> <Route path="warehouse-dashboard" element={<RequirePermission permission={FREIGHT_PERMS.warehouseDashboard.view}><WarehouseDashboardPage /></RequirePermission>} />
<Route
path="export-djibouti-unloading"
element={<ExportDjiboutiUnloadingQueuePage />}
/>
<Route
path="interchange-documents"
element={<InterchangeDocumentsPage />}
/>
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route
path="warehouse-fee-invoices"
element={<WarehouseInvoicesPage />}
/>
<Route
path="warehouse-dashboard"
element={<WarehouseDashboardPage />}
/>
<Route <Route
path="operations/train-scheduling" path="operations/train-scheduling"
@@ -1336,62 +1330,6 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="operations/train-scheduling"
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
>
<RoutesPage />
</RequirePermission>
}
/>
<Route <Route
path="fuel-purchases" path="fuel-purchases"
element={ element={
@@ -1472,108 +1410,6 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="locomotives"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="train-builder"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainBuilderListPage />
</RequirePermission>
}
/>
<Route
path="train-builder/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainBuilderDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="wagon-transfers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
]}
>
<WagonTransfersPage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
{/* Legacy embedded user management routes */} {/* Legacy embedded user management routes */}
{/* <Route path="user-management" element={<UserManagementPage />} /> {/* <Route path="user-management" element={<UserManagementPage />} />

View File

@@ -68,6 +68,8 @@ export function ClearanceOpsTabs({
Boolean(exchangeEntityId) && Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || (hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)); hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
// Risk assignment + incident reporting hit bookings:operations endpoints.
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange; const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) { if (!hasTabs) {
@@ -98,12 +100,12 @@ export function ClearanceOpsTabs({
Document exchange Document exchange
</Tabs.Tab> </Tabs.Tab>
) : null} ) : null}
{showOpsTabs && riskMs ? ( {showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}> <Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment Risk assignment
</Tabs.Tab> </Tabs.Tab>
) : null} ) : null}
{showOpsTabs && bookingId ? ( {showOpsTabs && canOps && bookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}> <Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents Incidents
</Tabs.Tab> </Tabs.Tab>
@@ -129,7 +131,7 @@ export function ClearanceOpsTabs({
</Tabs.Panel> </Tabs.Panel>
) : null} ) : null}
{showOpsTabs && riskMs && bookingId ? ( {showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk"> <Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green"> <SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
<AssignRiskCard bookingId={bookingId} milestone={riskMs} /> <AssignRiskCard bookingId={bookingId} milestone={riskMs} />
@@ -137,7 +139,7 @@ export function ClearanceOpsTabs({
</Tabs.Panel> </Tabs.Panel>
) : null} ) : null}
{showOpsTabs && bookingId ? ( {showOpsTabs && canOps && bookingId ? (
<Tabs.Panel value="incidents"> <Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green"> <SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm"> <Stack gap="sm">

View File

@@ -41,6 +41,8 @@ import {
fetchViewableFile, fetchViewableFile,
} from "@/services/files.service"; } from "@/services/files.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts"; import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
export interface ContractClearanceReviewSectionProps { export interface ContractClearanceReviewSectionProps {
@@ -123,6 +125,21 @@ export function ContractClearanceReviewSection({
} | null>(null); } | null>(null);
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
// Mirror the API guards: Path A (self-clearance) actions need
// ops_clearance_review; Path B (customs) review needs clearance_review or
// the ET phased key. Without the matching key every action would 403 — show
// the audit view instead of dead buttons.
const { user } = useAuth();
const canReviewHere = selfClear
? hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview)
: hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Finalize has its own API key on the customs path (contracts:finalize_clearance).
const canFinalizeHere = selfClear
? hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview)
: hasPermission(user, FREIGHT_PERMS.contracts.finalizeClearance);
readOnly = readOnly || !canReviewHere;
const reviewerTeam = selfClear ? "Operations" : "Global Logistics"; const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
const { data: clearance, isLoading } = useQuery({ const { data: clearance, isLoading } = useQuery({
@@ -484,7 +501,7 @@ export function ContractClearanceReviewSection({
color="edr-green" color="edr-green"
radius="md" radius="md"
leftSection={<CheckCircle2 size={16} />} leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved} disabled={!clearance.allApproved || !canFinalizeHere}
loading={finalizeClearance.isPending} loading={finalizeClearance.isPending}
onClick={() => onClick={() =>
finalizeClearance.mutate(undefined, { finalizeClearance.mutate(undefined, {

View File

@@ -19,6 +19,8 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling"; import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
interface ScheduleBatchPanelProps { interface ScheduleBatchPanelProps {
schedule: TrainScheduleDetail; schedule: TrainScheduleDetail;
@@ -31,6 +33,8 @@ const windowColor: Record<string, string> = {
}; };
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) { export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
const { user } = useAuth();
const canMarkPaid = hasPermission(user, FREIGHT_PERMS.trainScheduling.markPaid);
const { toast } = useToast(); const { toast } = useToast();
const actions = { const actions = {
runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()), runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()),
@@ -179,7 +183,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Table.Td> <Table.Td>
{!locked && ( {!locked && (
<Group gap={6} justify="flex-end" wrap="nowrap"> <Group gap={6} justify="flex-end" wrap="nowrap">
{b.status !== "PAID" && ( {canMarkPaid && b.status !== "PAID" && (
<Button <Button
size="compact-xs" size="compact-xs"
variant="light" variant="light"

View File

@@ -6,7 +6,32 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:overview:view", view: "edr_freight_app:overview:view",
}, },
support: { support: {
view: "edr_freight_app:support:view", agentView: "edr_freight_app:support:agent_view",
agentSend: "edr_freight_app:support:agent_send",
},
reports: {
view: "edr_freight_app:reports:view",
},
procurement: {
view: "edr_freight_app:procurement:view",
vendorManage: "edr_freight_app:procurement:vendor_manage",
acquisitionManage: "edr_freight_app:procurement:acquisition_manage",
disposalManage: "edr_freight_app:procurement:disposal_manage",
},
compliance: {
view: "edr_freight_app:compliance:view",
manage: "edr_freight_app:compliance:manage",
},
facilities: {
view: "edr_freight_app:facilities:view",
manage: "edr_freight_app:facilities:manage",
},
tradeAccess: {
view: "edr_freight_app:trade_access:view",
manage: "edr_freight_app:trade_access:manage",
},
staffUsers: {
view: "edr_freight_app:staff:users:view",
}, },
bookings: { bookings: {
view: "edr_freight_app:bookings:view", view: "edr_freight_app:bookings:view",
@@ -27,6 +52,7 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance", finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert", docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
governmentExpedite: "edr_freight_app:bookings:government_expedite",
}, },
contracts: { contracts: {
view: "edr_freight_app:contracts:view", view: "edr_freight_app:contracts:view",
@@ -61,6 +87,9 @@ export const FREIGHT_PERMS = {
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions", clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend", suspend: "edr_freight_app:contracts:suspend",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
}, },
trainScheduling: { trainScheduling: {
view: "edr_freight_app:train_scheduling:view", view: "edr_freight_app:train_scheduling:view",
@@ -69,6 +98,9 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:train_scheduling:cancel", cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule", reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage", rulesManage: "edr_freight_app:train_scheduling:rules_manage",
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
}, },
fleet: { fleet: {
view: "edr_freight_app:fleet:view", view: "edr_freight_app:fleet:view",
@@ -88,13 +120,9 @@ export const FREIGHT_PERMS = {
}, },
payments: { payments: {
view: "edr_freight_app:payments:view", view: "edr_freight_app:payments:view",
verify: "edr_freight_app:payments:verify",
refund: "edr_freight_app:payments:refund",
}, },
invoices: { invoices: {
view: "edr_freight_app:invoices:view", view: "edr_freight_app:invoices:view",
create: "edr_freight_app:invoices:create",
cancel: "edr_freight_app:invoices:cancel",
export: "edr_freight_app:invoices:export", export: "edr_freight_app:invoices:export",
}, },
firstMile: { firstMile: {
@@ -191,14 +219,12 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:fuel:create", create: "edr_freight_app:fuel:create",
update: "edr_freight_app:fuel:update", update: "edr_freight_app:fuel:update",
delete: "edr_freight_app:fuel:delete", delete: "edr_freight_app:fuel:delete",
approve: "edr_freight_app:fuel:approve",
}, },
maintenance: { maintenance: {
view: "edr_freight_app:maintenance:view", view: "edr_freight_app:maintenance:view",
create: "edr_freight_app:maintenance:create", create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update", update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete", delete: "edr_freight_app:maintenance:delete",
complete: "edr_freight_app:maintenance:complete",
}, },
fleetReports: { fleetReports: {
view: "edr_freight_app:fleet_reports:view", view: "edr_freight_app:fleet_reports:view",
@@ -278,6 +304,14 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", manage: "edr_freight_app:settings:dropdown:manage",
}, },
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
},
}, },
audit: { audit: {
view: "edr_freight_app:audit:view", view: "edr_freight_app:audit:view",

View File

@@ -13,6 +13,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react"; import { ArrowLeft, Download } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -60,6 +62,8 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
} }
export default function InvoiceDetailPage() { export default function InvoiceDetailPage() {
const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
@@ -124,6 +128,7 @@ export default function InvoiceDetailPage() {
size="lg" size="lg"
radius="md" radius="md"
aria-label="Download invoice" aria-label="Download invoice"
disabled={!canExport}
loading={downloading} loading={downloading}
onClick={() => void downloadDocument()} onClick={() => void downloadDocument()}
> >

View File

@@ -51,6 +51,8 @@ import {
} from "@/features/support/useSupport"; } from "@/features/support/useSupport";
import { useSupportSocket } from "@/features/support/useSupportSocket"; import { useSupportSocket } from "@/features/support/useSupportSocket";
import { customersService } from "@/services/customers.service"; import { customersService } from "@/services/customers.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type ReadFilter = "ALL" | "UNREAD"; type ReadFilter = "ALL" | "UNREAD";
@@ -401,6 +403,8 @@ function ConversationThread({
fetchNextPage, fetchNextPage,
} = useMessages(conversation.id); } = useMessages(conversation.id);
const send = useSendMessage(conversation.id); const send = useSendMessage(conversation.id);
const { user: agentUser } = useAuth();
const canSend = hasPermission(agentUser, FREIGHT_PERMS.support.agentSend);
const markRead = useMarkConversationRead(); const markRead = useMarkConversationRead();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@@ -656,7 +660,7 @@ function ConversationThread({
color="edr-green" color="edr-green"
variant="filled" variant="filled"
loading={send.isPending} loading={send.isPending}
disabled={!draft.trim() && attach.attachments.length === 0} disabled={!canSend || (!draft.trim() && attach.attachments.length === 0)}
onClick={submit} onClick={submit}
> >
<Send size={18} /> <Send size={18} />

View File

@@ -42,6 +42,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { KpiStrip, PageContainer } from "@/components/page"; import { KpiStrip, PageContainer } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
autoFillPlacements, autoFillPlacements,
mergePlacementsWithSaved, mergePlacementsWithSaved,
@@ -102,6 +104,7 @@ const parseError = (error: unknown, fallback: string) => {
}; };
export default function TrainScheduleV2DetailPage() { export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>(); const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast(); const { toast } = useToast();
const [activeStep, setActiveStep] = useState(0); const [activeStep, setActiveStep] = useState(0);
@@ -394,7 +397,9 @@ export default function TrainScheduleV2DetailPage() {
: []; : [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status); const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canDispatch = schedule.status === "SCHEDULED"; const canDispatch =
schedule.status === "SCHEDULED" &&
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose // Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can // cargo staff never marked loaded. Both are warnings, not blockers — staff can

View File

@@ -63,7 +63,7 @@ import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service"; import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions"; import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
import type { import type {
CreateScheduleWindowRulePayload, CreateScheduleWindowRulePayload,
FreightType, FreightType,
@@ -111,7 +111,7 @@ export default function TrainScheduleV2ListPage() {
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth(); const { user } = useAuth();
const canCreate = canCreateSchedule(user); const canCreate = canCreateSchedule(user);
const canUpdate = canUpdateSchedule(user); const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2"); const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -491,7 +491,7 @@ export default function TrainScheduleV2ListPage() {
{/* Start the run. Same transition as the detail page's {/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */} and not-loaded warnings, so it stays the fuller surface. */}
{canUpdate && schedule.status === "SCHEDULED" ? ( {canDispatch && schedule.status === "SCHEDULED" ? (
<Menu.Item <Menu.Item
leftSection={<Play size={15} />} leftSection={<Play size={15} />}
onClick={() => setDispatchTarget(schedule)} onClick={() => setDispatchTarget(schedule)}

View File

@@ -38,6 +38,8 @@ import {
import { openPdfBlob } from '@/components/warehouses/pdf'; import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf'; import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options'; import { extractErrorMessage } from '@/components/warehouses/options';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = { const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray', DRAFT: 'gray',
@@ -171,6 +173,9 @@ export default function WarehouseInvoicesPage() {
} }
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { user } = useAuth();
const mayRecordPayment = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.pay);
const canCancelInvoice = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.cancel);
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: inv, isLoading } = useQuery( const { data: inv, isLoading } = useQuery(
@@ -430,9 +435,11 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
placeholder="Optional" placeholder="Optional"
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}> {mayRecordPayment && (
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'} <Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
</Button> Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
)}
</Group> </Group>
<Divider label="Record manual payment" labelPosition="left" /> <Divider label="Record manual payment" labelPosition="left" />
@@ -456,9 +463,11 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
onChange={(e) => setDriverPhone(e.currentTarget.value)} onChange={(e) => setDriverPhone(e.currentTarget.value)}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}> {mayRecordPayment && (
Pay <Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
</Button> Pay
</Button>
)}
</Group> </Group>
</> </>
)} )}
@@ -506,7 +515,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
Gate clearance & exit paper Gate clearance & exit paper
</Button> </Button>
)} )}
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && ( {inv.status !== 'PAID' && inv.status !== 'CANCELLED' && canCancelInvoice && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}> <Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice Cancel invoice
</Button> </Button>