diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts index 8024556a9..0aba110d1 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -1,10 +1,15 @@ -import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtGuard } from '../../common/jwt.guard'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { RescheduleService } from './reschedule.service'; -import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto'; +import { + CreateReschedulePolicyDto, + CreateRescheduleDto, + RescheduleQuoteDto, + UpdateReschedulePolicyDto, +} from './reschedule.dto'; @ApiTags('Reschedule') @Controller() @@ -14,11 +19,27 @@ export class RescheduleController { @Get('reschedule/policies') @PassengerStaff(PASSENGER_PERMS.bookings.view) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Reschedule policy per coach type (fare class)' }) + @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' }) listPolicies() { return this.service.listPolicies(); } + @Get('reschedule/policies/available-coach-types') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' }) + listUnconfiguredCoachTypes() { + return this.service.listUnconfiguredCoachTypes(); + } + + @Post('reschedule/policies') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' }) + createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) { + return this.service.createPolicy(dto, req.user?.id); + } + @Patch('reschedule/policies/:coachTypeId') @PassengerAdmin() @ApiBearerAuth('JWT-auth') @@ -27,6 +48,14 @@ export class RescheduleController { return this.service.updatePolicy(coachTypeId, dto, req.user?.id); } + @Delete('reschedule/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' }) + deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { + return this.service.deletePolicy(coachTypeId, req.user?.id); + } + @Get('bookings/:bookingRef/reschedule') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts index d41fbc9a8..479896adf 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts @@ -45,6 +45,13 @@ export class UpdateReschedulePolicyDto { isActive?: boolean; } +/** Same fields as the update DTO, plus the fare class the new policy attaches to. */ +export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto { + @ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' }) + @IsString() + coachTypeId: string; +} + export class RescheduleQuoteDto { @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index 4b2019938..a0a8670f6 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, Logger, @@ -19,11 +20,33 @@ import { TicketsService } from '../tickets/tickets.service'; import { PaymentsService } from '../payments/payments.service'; import { SupplementaryChargesService } from '../payments/supplementary-charges.service'; import { CurrencyService } from '../currency/currency.service'; -import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto'; +import { + CreateReschedulePolicyDto, + CreateRescheduleDto, + RescheduleQuoteDto, + UpdateReschedulePolicyDto, +} from './reschedule.dto'; export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE'; export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid'; +/** + * Coaches nobody buys a seat in, so they can never carry a reschedule policy. + * + * Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' | + * 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space + * included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the + * dining coach as a fare class. This mirrors the portal's own test (`/dining|dpc/i`, + * booking/seats/page.tsx) and checks `code` as well as `type`. + */ +const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage']; +const NOT_A_FARE_CLASS = { + NOT: NON_FARE_COACH_TERMS.flatMap((term) => [ + { type: { contains: term, mode: 'insensitive' as const } }, + { code: { contains: term, mode: 'insensitive' as const } }, + ]), +}; + type PolicyNumbers = { feePercent: number; feeMinMinor: number; @@ -90,18 +113,62 @@ export class RescheduleService { // ── Policy admin ───────────────────────────────────────────────────────── + /** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */ async listPolicies() { - const coachTypes = await this.prisma.coachType.findMany({ - where: { type: { notIn: ['dining', 'baggage'] } }, - include: { reschedulePolicy: true }, + return this.prisma.reschedulePolicy.findMany({ + include: { coachType: { select: { id: true, code: true, name: true, type: true } } }, + orderBy: { coachType: { code: 'asc' } }, + }); + } + + /** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */ + async listUnconfiguredCoachTypes() { + return this.prisma.coachType.findMany({ + where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } }, + select: { id: true, code: true, name: true, type: true }, orderBy: { code: 'asc' }, }); - return coachTypes.map((ct) => ({ - coachTypeId: ct.id, - code: ct.code, - name: ct.name, - policy: ct.reschedulePolicy, - })); + } + + async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) { + const { coachTypeId, ...values } = dto; + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + if (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) { + throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`); + } + const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } }); + if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`); + + const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + newData: { coachTypeCode: coachType.code, ...values }, + }); + return policy; + } + + async deletePolicy(coachTypeId: string, actorId?: string) { + const policy = await this.prisma.reschedulePolicy.findUnique({ + where: { coachTypeId }, + include: { coachType: { select: { code: true } } }, + }); + if (!policy) throw new NotFoundException('Reschedule policy not found'); + + await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + oldData: policy, + }); + // Rescheduling for this fare class is now refused outright (legBlockers treats a missing + // policy the same as an inactive one), which is the intended effect of deleting it. + return { deleted: true, coachTypeId }; } async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) { diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx new file mode 100644 index 000000000..8e7a60fce --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx new file mode 100644 index 000000000..31f265b6c --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import ReschedulePolicyManager from '@/components/reschedule/ReschedulePolicyManager'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +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. + */ +export default function ReschedulePoliciesPage() { + return ( + +
+
+

Reschedule Policies

+

+ Rules that decide whether a booked journey can be moved, and what the change costs +

+
+ +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index a97254898..90725b683 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'; import { Save } from 'lucide-react'; -import { systemConfigApi, reschedulePolicyApi, type ReschedulePolicyRow } from '@/lib/api'; +import { systemConfigApi } from '@/lib/api'; -type Tab = 'general' | 'payment' | 'integrations' | 'configurations' | 'reschedule'; +type Tab = 'general' | 'payment' | 'integrations' | 'configurations'; export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); @@ -57,7 +57,6 @@ export default function SettingsPage() { const tabs: { id: Tab; label: string }[] = [ { id: 'general', label: 'General' }, { id: 'configurations', label: 'Configurations' }, - { id: 'reschedule', label: 'Reschedule Policy' }, ]; return ( @@ -112,7 +111,6 @@ export default function SettingsPage() { )} - {activeTab === 'reschedule' && } {activeTab === 'configurations' && (
@@ -227,125 +225,3 @@ export default function SettingsPage() {
); } - -type PolicyForm = NonNullable; - -const EMPTY_POLICY: PolicyForm = { - feePercent: 0, feeMinMinor: 0, routeChangeAllowed: true, sameDayAllowed: true, - sameDayFeePercent: 0, sameDayFeeMinMinor: 0, cutoffMinutes: 60, isActive: true, -}; - -/** Policy §3 — one editable row per fare class (HSC = Standard, HBC = Flex, SBC = Premium). Money is entered in ETB, stored in minor units. */ -function ReschedulePolicyTab() { - const [rows, setRows] = useState([]); - const [forms, setForms] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(null); - const [message, setMessage] = useState(''); - - useEffect(() => { - reschedulePolicyApi.list() - .then((data) => { - const list = Array.isArray(data) ? data : []; - setRows(list); - setForms(Object.fromEntries(list.map((r) => [r.coachTypeId, { ...EMPTY_POLICY, ...(r.policy ?? {}) }]))); - }) - .catch(() => setMessage('Failed to load policies.')) - .finally(() => setLoading(false)); - }, []); - - const setField = (id: string, patch: Partial) => - setForms((f) => ({ ...f, [id]: { ...f[id], ...patch } })); - - const save = async (id: string) => { - setSaving(id); - setMessage(''); - try { - await reschedulePolicyApi.update(id, forms[id]); - setMessage('Saved.'); - } catch { - setMessage('Failed to save.'); - } finally { - setSaving(null); - } - }; - - const etb = (minor: number) => String(minor / 100); - const minor = (etbValue: string) => Math.round(Number(etbValue || 0) * 100); - - if (loading) return

Loading...

; - - return ( -
-
-

Rescheduling rules per fare class

-

- Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is not refunded. - Same-day = new departure on the same calendar day as the original. -

-
- {rows.map((r) => { - const f = forms[r.coachTypeId]; - return ( -
-
-
- {r.code} - — {r.name} - {!r.policy && no policy yet (rescheduling disabled)} -
- -
-
-
- - setField(r.coachTypeId, { feePercent: Number(e.target.value) })} /> -
-
- - setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} /> -
-
- - setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} /> -
-
- - -
-
- - -
-
- - setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} /> -
-
- - setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} /> -
-
- -
-
-
- ); - })} - {rows.length === 0 &&

No passenger coach types found.

} - {message && {message}} -
- ); -} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index de8619577..3b74eb7dc 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -28,6 +28,7 @@ import { FileText, Briefcase, Calendar, + CalendarClock, Utensils, Package, Moon, @@ -88,6 +89,7 @@ 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 }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx new file mode 100644 index 000000000..efc1ca5d2 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx @@ -0,0 +1,376 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Edit, Plus, Save, Trash2 } from 'lucide-react'; +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 { + reschedulePolicyApi, + type ReschedulePolicyCoachType, + type ReschedulePolicyRow, + type ReschedulePolicyValues, +} from '@/lib/api'; + +const EMPTY_POLICY: ReschedulePolicyValues = { + feePercent: 0, + feeMinMinor: 0, + routeChangeAllowed: true, + sameDayAllowed: true, + sameDayFeePercent: 0, + sameDayFeeMinMinor: 0, + cutoffMinutes: 60, + isActive: true, +}; + +// Money is entered in ETB and stored in minor units. +const etb = (minor: number) => String(minor / 100); +const toMinor = (value: string) => Math.round(Number(value || 0) * 100); +const feeLabel = (percent: number, minMinor: number) => + percent > 0 || minMinor > 0 ? `${percent}% · min ETB ${etb(minMinor)}` : 'Free'; + +/** + * Policy §3 - one policy per fare class (coach type), listed as a table and edited in a dialog, + * the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all. + */ +export default function ReschedulePolicyManager() { + const [rows, setRows] = useState([]); + const [available, setAvailable] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(''); + + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [coachTypeId, setCoachTypeId] = useState(''); + const [form, setForm] = useState(EMPTY_POLICY); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(''); + + const [deleting, setDeleting] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + + const load = async () => { + setLoading(true); + try { + const [policies, coachTypes] = await Promise.all([ + reschedulePolicyApi.list(), + reschedulePolicyApi.availableCoachTypes(), + ]); + setRows(Array.isArray(policies) ? policies : []); + setAvailable(Array.isArray(coachTypes) ? coachTypes : []); + } catch { + setMessage('Failed to load reschedule policies.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = () => { + setEditing(null); + setCoachTypeId(''); + setForm(EMPTY_POLICY); + setFormError(''); + setShowModal(true); + }; + + const openEdit = (row: ReschedulePolicyRow) => { + setEditing(row); + setCoachTypeId(row.coachTypeId); + setForm({ + feePercent: row.feePercent, + feeMinMinor: row.feeMinMinor, + routeChangeAllowed: row.routeChangeAllowed, + sameDayAllowed: row.sameDayAllowed, + sameDayFeePercent: row.sameDayFeePercent, + sameDayFeeMinMinor: row.sameDayFeeMinMinor, + cutoffMinutes: row.cutoffMinutes, + isActive: row.isActive, + }); + setFormError(''); + setShowModal(true); + }; + + const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const submit = async () => { + if (!editing && !coachTypeId) { + setFormError('Pick a fare class.'); + return; + } + setSaving(true); + setFormError(''); + try { + if (editing) await reschedulePolicyApi.update(editing.coachTypeId, form); + else await reschedulePolicyApi.create({ coachTypeId, ...form }); + setShowModal(false); + setMessage(editing ? 'Policy updated.' : 'Policy created.'); + await load(); + } catch (err: any) { + setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.'); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleting) return; + setDeleteBusy(true); + try { + await reschedulePolicyApi.remove(deleting.coachTypeId); + setDeleting(null); + setMessage('Policy deleted.'); + await load(); + } catch { + setMessage('Failed to delete the policy.'); + } finally { + setDeleteBusy(false); + } + }; + + const columns = [ + { + key: 'coachType', + label: 'Fare class', + render: (row: ReschedulePolicyRow) => ( +
+ {row.coachType?.code} + - {row.coachType?.name} +
+ ), + }, + { + key: 'fee', + label: 'Change fee', + render: (row: ReschedulePolicyRow) => ( + {feeLabel(row.feePercent, row.feeMinMinor)} + ), + }, + { + key: 'routeChangeAllowed', + label: 'Route change', + render: (row: ReschedulePolicyRow) => ( + + {row.routeChangeAllowed ? 'Allowed' : 'Not permitted'} + + ), + }, + { + key: 'sameDay', + label: 'Same-day change', + render: (row: ReschedulePolicyRow) => + row.sameDayAllowed ? ( + {feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)} + ) : ( + Not permitted + ), + }, + { + key: 'cutoffMinutes', + label: 'Cutoff', + render: (row: ReschedulePolicyRow) => ( + {row.cutoffMinutes} min + ), + }, + { + key: 'isActive', + label: 'Status', + render: (row: ReschedulePolicyRow) => ( + + {row.isActive ? 'Active' : 'Disabled'} + + ), + }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { + label: 'Delete', + onClick: (row: ReschedulePolicyRow) => setDeleting(row), + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+ {/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */} +

+ Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is + 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. +

+ + Add Reschedule Policy + +
+ + {!loading && available.length === 0 && ( +

Every fare class already has a policy.

+ )} + {message &&

{message}

} + + + + setShowModal(false)} + title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'} + size="lg" + > +
+
+ + {editing ? ( + <> + + {/* One policy per fare class, so editing never re-points a row at another class. */} +

A policy stays attached to its fare class.

+ + ) : ( + + )} +
+ +
+
+ + setField({ feePercent: Number(e.target.value) })} + /> +
+
+ + setField({ feeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + setField({ cutoffMinutes: Number(e.target.value) })} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + setField({ sameDayFeePercent: Number(e.target.value) })} + /> +
+
+ + setField({ sameDayFeeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + {formError &&

{formError}

} + +
+ setShowModal(false)}> + Cancel + + + {editing ? 'Update Policy' : 'Create Policy'} + +
+
+
+ + setDeleting(null)} + onConfirm={confirmDelete} + title="Delete reschedule policy" + message={`Delete the reschedule policy for ${deleting?.coachType?.code ?? ''}?`} + warning="Passengers on this fare class will no longer be able to reschedule. Bookings already rescheduled are unaffected." + confirmText="Delete" + isDanger + isLoading={deleteBusy} + /> +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index b9b7d41f1..a5baf0071 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -527,26 +527,38 @@ export const systemConfigApi = { update: (data: Record) => apiClient.patch>('/config', data), }; -// Reschedule Policy API (one row per coach type = fare class) -export interface ReschedulePolicyRow { - coachTypeId: string; +// Reschedule Policy API — one policy per coach type (fare class). A coach type with no policy +// simply has no row, and rescheduling is refused for it. +export interface ReschedulePolicyValues { + feePercent: number; + feeMinMinor: number; + routeChangeAllowed: boolean; + sameDayAllowed: boolean; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; + cutoffMinutes: number; + isActive: boolean; +} +export interface ReschedulePolicyCoachType { + id: string; code: string; name: string; - policy: { - feePercent: number; - feeMinMinor: number; - routeChangeAllowed: boolean; - sameDayAllowed: boolean; - sameDayFeePercent: number; - sameDayFeeMinMinor: number; - cutoffMinutes: number; - isActive: boolean; - } | null; + type: string; +} +export interface ReschedulePolicyRow extends ReschedulePolicyValues { + id: string; + coachTypeId: string; + coachType: ReschedulePolicyCoachType; } export const reschedulePolicyApi = { list: () => apiClient.get('/reschedule/policies'), - update: (coachTypeId: string, data: Partial>) => - apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), + availableCoachTypes: () => + apiClient.get('/reschedule/policies/available-coach-types'), + create: (data: ReschedulePolicyValues & { coachTypeId: string }) => + apiClient.post('/reschedule/policies', data), + update: (coachTypeId: string, data: Partial) => + apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), + remove: (coachTypeId: string) => apiClient.delete(`/reschedule/policies/${coachTypeId}`), }; // App Releases API