mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat: ( reschedule ) manage reschedule policies from a Master Data page with full CRUD
This commit is contained in:
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
import { RescheduleService } from './reschedule.service';
|
import { RescheduleService } from './reschedule.service';
|
||||||
import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
|
import {
|
||||||
|
CreateReschedulePolicyDto,
|
||||||
|
CreateRescheduleDto,
|
||||||
|
RescheduleQuoteDto,
|
||||||
|
UpdateReschedulePolicyDto,
|
||||||
|
} from './reschedule.dto';
|
||||||
|
|
||||||
@ApiTags('Reschedule')
|
@ApiTags('Reschedule')
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -14,11 +19,27 @@ export class RescheduleController {
|
|||||||
@Get('reschedule/policies')
|
@Get('reschedule/policies')
|
||||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@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() {
|
listPolicies() {
|
||||||
return this.service.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')
|
@Patch('reschedule/policies/:coachTypeId')
|
||||||
@PassengerAdmin()
|
@PassengerAdmin()
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -27,6 +48,14 @@ export class RescheduleController {
|
|||||||
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
|
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')
|
@Get('bookings/:bookingRef/reschedule')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ export class UpdateReschedulePolicyDto {
|
|||||||
isActive?: boolean;
|
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 {
|
export class RescheduleQuoteDto {
|
||||||
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
|
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
|
||||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -19,11 +20,33 @@ import { TicketsService } from '../tickets/tickets.service';
|
|||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
|
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
|
||||||
import { CurrencyService } from '../currency/currency.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 RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
|
||||||
export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
|
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 = {
|
type PolicyNumbers = {
|
||||||
feePercent: number;
|
feePercent: number;
|
||||||
feeMinMinor: number;
|
feeMinMinor: number;
|
||||||
@@ -90,18 +113,62 @@ export class RescheduleService {
|
|||||||
|
|
||||||
// ── Policy admin ─────────────────────────────────────────────────────────
|
// ── Policy admin ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */
|
||||||
async listPolicies() {
|
async listPolicies() {
|
||||||
const coachTypes = await this.prisma.coachType.findMany({
|
return this.prisma.reschedulePolicy.findMany({
|
||||||
where: { type: { notIn: ['dining', 'baggage'] } },
|
include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
|
||||||
include: { reschedulePolicy: 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' },
|
orderBy: { code: 'asc' },
|
||||||
});
|
});
|
||||||
return coachTypes.map((ct) => ({
|
}
|
||||||
coachTypeId: ct.id,
|
|
||||||
code: ct.code,
|
async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) {
|
||||||
name: ct.name,
|
const { coachTypeId, ...values } = dto;
|
||||||
policy: ct.reschedulePolicy,
|
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) {
|
async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardLayout from '../dashboard/layout';
|
||||||
|
|
||||||
|
export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<PermissionGuard permission={PERMS.bookings.view}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Rules that decide whether a booked journey can be moved, and what the change costs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReschedulePolicyManager />
|
||||||
|
</div>
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Save } from 'lucide-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() {
|
export default function SettingsPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('general');
|
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||||
@@ -57,7 +57,6 @@ export default function SettingsPage() {
|
|||||||
const tabs: { id: Tab; label: string }[] = [
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
{ id: 'general', label: 'General' },
|
{ id: 'general', label: 'General' },
|
||||||
{ id: 'configurations', label: 'Configurations' },
|
{ id: 'configurations', label: 'Configurations' },
|
||||||
{ id: 'reschedule', label: 'Reschedule Policy' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +111,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'reschedule' && <ReschedulePolicyTab />}
|
|
||||||
|
|
||||||
{activeTab === 'configurations' && (
|
{activeTab === 'configurations' && (
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
@@ -227,125 +225,3 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PolicyForm = NonNullable<ReschedulePolicyRow['policy']>;
|
|
||||||
|
|
||||||
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<ReschedulePolicyRow[]>([]);
|
|
||||||
const [forms, setForms] = useState<Record<string, PolicyForm>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState<string | null>(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<PolicyForm>) =>
|
|
||||||
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 <div className="card"><p className="text-sm text-muted-foreground">Loading...</p></div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-foreground">Rescheduling rules per fare class</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{rows.map((r) => {
|
|
||||||
const f = forms[r.coachTypeId];
|
|
||||||
return (
|
|
||||||
<div key={r.coachTypeId} className="border border-border rounded-lg p-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold text-foreground">{r.code}</span>
|
|
||||||
<span className="text-muted-foreground"> — {r.name}</span>
|
|
||||||
{!r.policy && <span className="ml-2 text-xs text-amber-600">no policy yet (rescheduling disabled)</span>}
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input type="checkbox" checked={f.isActive} onChange={(e) => setField(r.coachTypeId, { isActive: e.target.checked })} />
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Fee (% of fare)</label>
|
|
||||||
<input type="number" min="0" max="100" className="input" value={f.feePercent} onChange={(e) => setField(r.coachTypeId, { feePercent: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Minimum fee (ETB)</label>
|
|
||||||
<input type="number" min="0" step="0.01" className="input" value={etb(f.feeMinMinor)} onChange={(e) => setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Cutoff before departure (min)</label>
|
|
||||||
<input type="number" min="0" className="input" value={f.cutoffMinutes} onChange={(e) => setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Route change</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm h-10">
|
|
||||||
<input type="checkbox" checked={f.routeChangeAllowed} onChange={(e) => setField(r.coachTypeId, { routeChangeAllowed: e.target.checked })} />
|
|
||||||
Allowed
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Same-day change</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm h-10">
|
|
||||||
<input type="checkbox" checked={f.sameDayAllowed} onChange={(e) => setField(r.coachTypeId, { sameDayAllowed: e.target.checked })} />
|
|
||||||
Allowed
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Same-day fee (% of fare)</label>
|
|
||||||
<input type="number" min="0" max="100" className="input" disabled={!f.sameDayAllowed} value={f.sameDayFeePercent} onChange={(e) => setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="label">Same-day minimum fee (ETB)</label>
|
|
||||||
<input type="number" min="0" step="0.01" className="input" disabled={!f.sameDayAllowed} value={etb(f.sameDayFeeMinMinor)} onChange={(e) => setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button className="btn btn-primary flex items-center gap-2" disabled={saving === r.coachTypeId} onClick={() => save(r.coachTypeId)}>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{saving === r.coachTypeId ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{rows.length === 0 && <p className="text-sm text-muted-foreground">No passenger coach types found.</p>}
|
|
||||||
{message && <span className="text-sm text-muted-foreground">{message}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarClock,
|
||||||
Utensils,
|
Utensils,
|
||||||
Package,
|
Package,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -88,6 +89,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
||||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
||||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
||||||
|
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<ReschedulePolicyRow[]>([]);
|
||||||
|
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ReschedulePolicyRow | null>(null);
|
||||||
|
const [coachTypeId, setCoachTypeId] = useState('');
|
||||||
|
const [form, setForm] = useState<ReschedulePolicyValues>(EMPTY_POLICY);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = useState<ReschedulePolicyRow | null>(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<ReschedulePolicyValues>) => 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) => (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
|
||||||
|
<span className="text-muted-foreground"> - {row.coachType?.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fee',
|
||||||
|
label: 'Change fee',
|
||||||
|
render: (row: ReschedulePolicyRow) => (
|
||||||
|
<span className="text-sm">{feeLabel(row.feePercent, row.feeMinMinor)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'routeChangeAllowed',
|
||||||
|
label: 'Route change',
|
||||||
|
render: (row: ReschedulePolicyRow) => (
|
||||||
|
<span className={`edr-badge ${row.routeChangeAllowed ? 'edr-badge-success' : 'edr-badge-danger'}`}>
|
||||||
|
{row.routeChangeAllowed ? 'Allowed' : 'Not permitted'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sameDay',
|
||||||
|
label: 'Same-day change',
|
||||||
|
render: (row: ReschedulePolicyRow) =>
|
||||||
|
row.sameDayAllowed ? (
|
||||||
|
<span className="text-sm">{feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="edr-badge edr-badge-danger">Not permitted</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cutoffMinutes',
|
||||||
|
label: 'Cutoff',
|
||||||
|
render: (row: ReschedulePolicyRow) => (
|
||||||
|
<span className="font-mono text-sm">{row.cutoffMinutes} min</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
label: 'Status',
|
||||||
|
render: (row: ReschedulePolicyRow) => (
|
||||||
|
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
|
||||||
|
{row.isActive ? 'Active' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */}
|
||||||
|
<p className="text-xs text-muted-foreground max-w-3xl">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
|
||||||
|
Add Reschedule Policy
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && available.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
|
||||||
|
)}
|
||||||
|
{message && <p className="text-sm text-muted-foreground">{message}</p>}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={rows}
|
||||||
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="No reschedule policies yet - add one to allow rescheduling."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Fare class</label>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={`${editing.coachType?.code} - ${editing.coachType?.name}`}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
{/* One policy per fare class, so editing never re-points a row at another class. */}
|
||||||
|
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
|
||||||
|
<option value="">Select a fare class...</option>
|
||||||
|
{available.map((ct) => (
|
||||||
|
<option key={ct.id} value={ct.id}>
|
||||||
|
{ct.code} - {ct.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Fee (% of fare)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
className="input"
|
||||||
|
value={form.feePercent}
|
||||||
|
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Minimum fee (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
className="input"
|
||||||
|
value={etb(form.feeMinMinor)}
|
||||||
|
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Cutoff before departure (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
className="input"
|
||||||
|
value={form.cutoffMinutes}
|
||||||
|
onChange={(e) => setField({ cutoffMinutes: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Route change</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.routeChangeAllowed}
|
||||||
|
onChange={(e) => setField({ routeChangeAllowed: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Allowed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Same-day change</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.sameDayAllowed}
|
||||||
|
onChange={(e) => setField({ sameDayAllowed: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Allowed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Status</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isActive}
|
||||||
|
onChange={(e) => setField({ isActive: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Same-day fee (% of fare)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
className="input"
|
||||||
|
disabled={!form.sameDayAllowed}
|
||||||
|
value={form.sameDayFeePercent}
|
||||||
|
onChange={(e) => setField({ sameDayFeePercent: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="label">Same-day minimum fee (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
className="input"
|
||||||
|
disabled={!form.sameDayAllowed}
|
||||||
|
value={etb(form.sameDayFeeMinMinor)}
|
||||||
|
onChange={(e) => setField({ sameDayFeeMinMinor: toMinor(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton icon={Save} onClick={submit} loading={saving}>
|
||||||
|
{editing ? 'Update Policy' : 'Create Policy'}
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleting}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -527,26 +527,38 @@ export const systemConfigApi = {
|
|||||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
|
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reschedule Policy API (one row per coach type = fare class)
|
// Reschedule Policy API — one policy per coach type (fare class). A coach type with no policy
|
||||||
export interface ReschedulePolicyRow {
|
// simply has no row, and rescheduling is refused for it.
|
||||||
coachTypeId: string;
|
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;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
policy: {
|
type: string;
|
||||||
feePercent: number;
|
}
|
||||||
feeMinMinor: number;
|
export interface ReschedulePolicyRow extends ReschedulePolicyValues {
|
||||||
routeChangeAllowed: boolean;
|
id: string;
|
||||||
sameDayAllowed: boolean;
|
coachTypeId: string;
|
||||||
sameDayFeePercent: number;
|
coachType: ReschedulePolicyCoachType;
|
||||||
sameDayFeeMinMinor: number;
|
|
||||||
cutoffMinutes: number;
|
|
||||||
isActive: boolean;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
export const reschedulePolicyApi = {
|
export const reschedulePolicyApi = {
|
||||||
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
|
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
|
||||||
update: (coachTypeId: string, data: Partial<NonNullable<ReschedulePolicyRow['policy']>>) =>
|
availableCoachTypes: () =>
|
||||||
apiClient.patch<any>(`/reschedule/policies/${coachTypeId}`, data),
|
apiClient.get<ReschedulePolicyCoachType[]>('/reschedule/policies/available-coach-types'),
|
||||||
|
create: (data: ReschedulePolicyValues & { coachTypeId: string }) =>
|
||||||
|
apiClient.post<ReschedulePolicyRow>('/reschedule/policies', data),
|
||||||
|
update: (coachTypeId: string, data: Partial<ReschedulePolicyValues>) =>
|
||||||
|
apiClient.patch<ReschedulePolicyRow>(`/reschedule/policies/${coachTypeId}`, data),
|
||||||
|
remove: (coachTypeId: string) => apiClient.delete<any>(`/reschedule/policies/${coachTypeId}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// App Releases API
|
// App Releases API
|
||||||
|
|||||||
Reference in New Issue
Block a user