mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add contact phone overrides for payment links in excess baggage and supplementary charges
This commit is contained in:
@@ -8,6 +8,10 @@ export class LogExcessBaggageDto {
|
||||
@IsOptional() @IsString() bookingReference?: string;
|
||||
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
|
||||
@IsOptional() @IsString() agentId?: string;
|
||||
@ApiPropertyOptional({ example: '+251911223344', description: 'Override the phone the payment link SMS should go to. Defaults to the booking contact phone.' })
|
||||
@IsOptional() @IsString() contactPhone?: string;
|
||||
@ApiPropertyOptional({ example: 'passenger@example.com', description: 'Override the email the payment link should also be sent to. Defaults to the booking contact email.' })
|
||||
@IsOptional() @IsString() contactEmail?: string;
|
||||
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
|
||||
@IsInt() @IsPositive() excessWeightKg: number;
|
||||
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })
|
||||
|
||||
@@ -115,8 +115,8 @@ export class ExcessBaggageService {
|
||||
|
||||
const totalMinor = feePerKgMinor * dto.excessWeightKg;
|
||||
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
||||
const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
|
||||
const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
|
||||
const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
|
||||
const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
|
||||
|
||||
const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING';
|
||||
const paidAt = dto.collectCash ? new Date() : null;
|
||||
|
||||
@@ -49,6 +49,8 @@ class CreateSupplementaryChargeDto {
|
||||
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
|
||||
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
|
||||
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
|
||||
@ApiPropertyOptional({ description: 'Override the phone the payment link SMS should go to. Falls back to the booking contact phone.', example: '+251911223344' }) @IsOptional() @IsString() contactPhone?: string;
|
||||
@ApiPropertyOptional({ description: 'Override the email the payment link should also be sent to. Falls back to the booking contact email.', example: 'passenger@example.com' }) @IsOptional() @IsString() contactEmail?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export class SupplementaryChargesService {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
createdBy: string;
|
||||
}) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
@@ -76,8 +78,8 @@ export class SupplementaryChargesService {
|
||||
},
|
||||
});
|
||||
|
||||
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
|
||||
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
|
||||
const phone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null);
|
||||
const email = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null);
|
||||
await this.sendLink(charge, booking.bookingRef, phone, email);
|
||||
|
||||
await this.auditService.log({
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { excessBaggageApi, apiClient } from '@/lib/api';
|
||||
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ExcessBaggagePage() {
|
||||
const [waiveReason, setWaiveReason] = useState('');
|
||||
const [waiveError, setWaiveError] = useState<string | null>(null);
|
||||
const [logModal, setLogModal] = useState(false);
|
||||
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false });
|
||||
const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
const [resendModal, setResendModal] = useState<any>(null);
|
||||
const [resendSuccess, setResendSuccess] = useState(false);
|
||||
@@ -54,12 +54,36 @@ export default function ExcessBaggagePage() {
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!logModal) return;
|
||||
const bookingRef = logForm.bookingReference.trim();
|
||||
if (!bookingRef) {
|
||||
setLogForm((prev) => ({ ...prev, paymentPhone: '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
|
||||
const items = response?.items ?? [];
|
||||
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
|
||||
if (!match) return;
|
||||
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
|
||||
setLogForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
|
||||
} catch {
|
||||
// Ignore lookup failures: the agent can still override the number manually.
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [logForm.bookingReference, logModal]);
|
||||
|
||||
const logMutation = useMutation({
|
||||
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
|
||||
setLogModal(false);
|
||||
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false });
|
||||
setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' });
|
||||
setLogError(null);
|
||||
},
|
||||
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
|
||||
@@ -178,7 +202,7 @@ export default function ExcessBaggagePage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
|
||||
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
|
||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
|
||||
Log Excess Luggage
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -256,6 +280,15 @@ export default function ExcessBaggagePage() {
|
||||
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment SMS Phone</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="e.g. +251911223344"
|
||||
value={logForm.paymentPhone}
|
||||
onChange={(e) => setLogForm({ ...logForm, paymentPhone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Excess Weight (kg)</label>
|
||||
<input
|
||||
@@ -282,7 +315,7 @@ export default function ExcessBaggagePage() {
|
||||
</label>
|
||||
{!logForm.collectCash && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A payment link will be sent to the passenger's email and phone on file.
|
||||
The payment link will be sent to the phone above and the booking's saved email when present.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -302,6 +335,7 @@ export default function ExcessBaggagePage() {
|
||||
bookingReference: logForm.bookingReference.trim(),
|
||||
excessWeightKg: parseInt(logForm.excessWeightKg),
|
||||
collectCash: logForm.collectCash,
|
||||
contactPhone: logForm.paymentPhone.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import { useCreateSupplementaryCharge } from './useSupplementaryCharges';
|
||||
|
||||
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
|
||||
@@ -14,13 +15,37 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
|
||||
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
|
||||
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const bookingRef = form.bookingRef.trim();
|
||||
if (!bookingRef) {
|
||||
setForm((prev) => ({ ...prev, paymentPhone: '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
|
||||
const items = response?.items ?? [];
|
||||
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
|
||||
if (!match) return;
|
||||
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
|
||||
setForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone }));
|
||||
} catch {
|
||||
// Ignore lookup failures here; the staff member can still type a phone override manually.
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [form.bookingRef, isOpen]);
|
||||
|
||||
const createMutation = useCreateSupplementaryCharge(() => {
|
||||
setCreateSuccess('Charge created and payment link sent.');
|
||||
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
|
||||
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' });
|
||||
setFormError(null);
|
||||
setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000);
|
||||
});
|
||||
@@ -31,7 +56,13 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
|
||||
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
|
||||
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
|
||||
try {
|
||||
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
|
||||
await createMutation.mutateAsync({
|
||||
bookingRef: form.bookingRef.trim(),
|
||||
amountMinor,
|
||||
reason: form.reason,
|
||||
notes: form.notes || undefined,
|
||||
contactPhone: form.paymentPhone.trim() || undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
|
||||
}
|
||||
@@ -52,6 +83,10 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
|
||||
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
|
||||
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment SMS Phone</label>
|
||||
<input className="input" placeholder="e.g. +251911223344" value={form.paymentPhone} onChange={(e) => setForm({ ...form, paymentPhone: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
|
||||
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
|
||||
@@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
|
||||
The payment link sends to the phone above, falling back to the booking's saved contact details if left empty. The link expires in 72 hours.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||
|
||||
@@ -13,8 +13,14 @@ export function useSupplementaryCharges(filters: { bookingRef?: string; status?:
|
||||
export function useCreateSupplementaryCharge(onSuccess: () => void) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
|
||||
paymentsApi.supplementary.create(data),
|
||||
mutationFn: (data: {
|
||||
bookingRef: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
}) => paymentsApi.supplementary.create(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
|
||||
onSuccess();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react';
|
||||
import { Download } from 'lucide-react';
|
||||
@@ -39,6 +39,7 @@ export default function TicketsPage() {
|
||||
const [excessTicket, setExcessTicket] = useState<any>(null);
|
||||
const [excessKg, setExcessKg] = useState('');
|
||||
const [excessCollectCash, setExcessCollectCash] = useState(false);
|
||||
const [excessPaymentPhone, setExcessPaymentPhone] = useState('');
|
||||
const [excessError, setExcessError] = useState<string | null>(null);
|
||||
const [excessResult, setExcessResult] = useState<any>(null);
|
||||
|
||||
@@ -163,10 +164,35 @@ export default function TicketsPage() {
|
||||
onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!excessModalOpen || !excessTicket) return;
|
||||
const bookingRef = excessTicket?.booking?.bookingRef ?? '';
|
||||
if (!bookingRef) {
|
||||
setExcessPaymentPhone('');
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 });
|
||||
const items = response?.items ?? [];
|
||||
const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0];
|
||||
if (!match) return;
|
||||
const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? '';
|
||||
setExcessPaymentPhone((prev) => prev || nextPhone);
|
||||
} catch {
|
||||
// Ignore lookup failures here; the staff member can still type a phone override manually.
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [excessModalOpen, excessTicket]);
|
||||
|
||||
const openExcessModal = (ticket: any) => {
|
||||
setExcessTicket(ticket);
|
||||
setExcessKg('');
|
||||
setExcessCollectCash(false);
|
||||
setExcessPaymentPhone('');
|
||||
setExcessError(null);
|
||||
setExcessResult(null);
|
||||
setExcessModalOpen(true);
|
||||
@@ -179,6 +205,7 @@ export default function TicketsPage() {
|
||||
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
|
||||
excessWeightKg: parseInt(excessKg),
|
||||
collectCash: excessCollectCash,
|
||||
contactPhone: excessPaymentPhone.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -979,6 +1006,15 @@ export default function TicketsPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment SMS Phone</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="e.g. +251911223344"
|
||||
value={excessPaymentPhone}
|
||||
onChange={(e) => setExcessPaymentPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -200,8 +200,14 @@ export const paymentsApi = {
|
||||
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
|
||||
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
|
||||
supplementary: {
|
||||
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
|
||||
apiClient.post<any>('/payments/supplementary', data),
|
||||
create: (data: {
|
||||
bookingRef: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
notes?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
}) => apiClient.post<any>('/payments/supplementary', data),
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
|
||||
|
||||
Reference in New Issue
Block a user