Add cheque to payment UI

This commit is contained in:
2026-09-18 11:47:34 +05:30
parent 63e7ddba82
commit 1f6d63a0b3
4 changed files with 168 additions and 18 deletions
@@ -1,11 +1,36 @@
# [Refactor] Ride Lifecycle — Route-Free, Type-Free Rides # [Refactor] Ride Lifecycle — Route-Free, Type-Free Rides
**Status**: Proposed (design only — no code changes yet) **Status**: Mostly implemented — see [Implementation Status](#implementation-status-2026-09-17)
**Refactors**: [../03_ride_lifecycle.md](../03_ride_lifecycle.md) (kept as-is, this is the target design) **Refactors**: [../03_ride_lifecycle.md](../03_ride_lifecycle.md) (kept as-is, this is the target design)
**Depends On**: 02 — Stock Ledger **Depends On**: 02 — Stock Ledger
--- ---
## Implementation Status (2026-09-17)
**Done**, verified against `Models/DeliveryRun.cs`, `Features/Ride/*`:
- `RideStatus` collapsed to `Open`/`Cancelled`/`Closed` — no Draft/Active stage anywhere.
- `FK_RouteId`, `RideType`, `ActivatedAt`, `FK_SourceWarehouseId` are all gone from `DeliveryRun`.
- `POST /api/rides` creates directly into `Open` with `StartedAt` set; no `/open`/`/activate` routes.
- `POST /api/rides/{id}/cancel` cancels an `Open` ride directly — no approval step, no
`CancelRequested` stage.
- Driver compliance check (`CheckDriverComplianceAsync`) and the "vehicle can't have two Open rides"
guard are both wired into `RideService.CreateAsync`.
- Dead `RideCancelRequest` model/repository/table removed (2026-09-17) — see Migration Notes.
**Not done / diverges from this doc:**
1. **`GET /api/rides/breakdown-assist/{rideId}`** — not implemented at all (no route in
`RideController.cs`), not even the trimmed "vehicle stock levels only" version this doc describes.
2. **Impact item 1 (delivery task generation) confirmed still broken, honestly**:
`DeliveryTaskService.GenerateAsync` is a one-line `throw new NotImplementedException("... route-
based dispatch not yet implemented.")` — matches this doc's prediction exactly, not yet designed.
3. **Impact item 4 (stock reconciliation at Close/Cancel without a source warehouse)** — still
unaddressed; `CloseAsync`/`CancelAsync` do not touch `StockBalance` at all.
4. **Impact item 2 (loading sheet auto-population)** — resolved differently than either this doc or
`loading_event.md` predicted; see that document's own Implementation Status section.
---
## Goal ## Goal
Ride should not require a `Route`, should not carry a `RideType`, should not be gated by a warehouse's `Type`, and should not even pin down a source warehouse on the ride record itself. Per direction from the business owner, the ride record carries only what a real dispatch needs to track — **"a ride only carries vehicle, driver assign, and meter reading from start to end."** Everything route-shaped (which stores get visited, which orders/items travel on this trip) and everything warehouse-shaped (where stock loads from/returns to) moves out of the Ride entity's responsibility entirely; neither is replaced by another matching mechanism here. Ride should not require a `Route`, should not carry a `RideType`, should not be gated by a warehouse's `Type`, and should not even pin down a source warehouse on the ride record itself. Per direction from the business owner, the ride record carries only what a real dispatch needs to track — **"a ride only carries vehicle, driver assign, and meter reading from start to end."** Everything route-shaped (which stores get visited, which orders/items travel on this trip) and everything warehouse-shaped (where stock loads from/returns to) moves out of the Ride entity's responsibility entirely; neither is replaced by another matching mechanism here.
@@ -215,9 +240,10 @@ Not part of this docs-only pass, but captured for the eventual implementation st
`DeliveryRun`/`Ride` and their Fluent/EF configuration. `Status` stays, re-typed to the narrowed `DeliveryRun`/`Ride` and their Fluent/EF configuration. `Status` stays, re-typed to the narrowed
`RideStatus` (`Open`/`Cancelled`/`Closed`). Add `CancelTypeCode`, `CancelReason`, `CancelledAt` `RideStatus` (`Open`/`Cancelled`/`Closed`). Add `CancelTypeCode`, `CancelReason`, `CancelledAt`
columns to `DeliveryRun`. columns to `DeliveryRun`.
- Drop the `RideCancelRequest` table, its Fluent/EF configuration, and - ~~Drop the `RideCancelRequest` table, its Fluent/EF configuration, and
`RideCancelRequestRepository`/`IRideCancelRequestRepository` — the request/approve/reject flow is `RideCancelRequestRepository`/`IRideCancelRequestRepository`~~ — **Done (2026-09-17)**: model,
gone. repository, `DbSet`, and seed data removed; `CancelRequestStatus` enum removed as unused;
migration `RemoveRideCancelRequest` applied, dropping the table.
- Remove `RouteId`, `RideType`, and `SourceWarehouseId`/`SourceWarehouseName` from - Remove `RouteId`, `RideType`, and `SourceWarehouseId`/`SourceWarehouseName` from
`CreateRideRequest`, `RideResponse`, and `BreakdownAssistRideResponse` in `RideDTOs.cs`. Remove the `CreateRideRequest`, `RideResponse`, and `BreakdownAssistRideResponse` in `RideDTOs.cs`. Remove the
open-orders list from `BreakdownAssistResponse`. Remove the `RaiseCancelRequest`/cancel-request open-orders list from `BreakdownAssistResponse`. Remove the `RaiseCancelRequest`/cancel-request
@@ -11,6 +11,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@@ -25,11 +26,23 @@ import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, getApiError } from '@/lib/api/index' import { payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse } from '@/types/api' import type { InvoiceResponse } from '@/types/api'
const schema = z.object({ const schema = z
.object({
invoiceId: z.string().min(1, 'Invoice is required'), invoiceId: z.string().min(1, 'Invoice is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'), amount: z.number().min(0.01, 'Amount must be greater than 0'),
method: z.enum(['Cash', 'Credit']), method: z.enum(['Cash', 'Cheque', 'Credit']),
}) entryMode: z.enum(['RealTime', 'DayEndManual']),
chequeNumber: z.string().optional(),
chequeDate: z.string().optional(),
})
.refine((data) => data.method !== 'Cheque' || !!data.chequeNumber, {
message: 'Cheque number is required for cheque payments',
path: ['chequeNumber'],
})
.refine((data) => data.method !== 'Cheque' || !!data.chequeDate, {
message: 'Cheque date is required for cheque payments',
path: ['chequeDate'],
})
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
@@ -50,18 +63,35 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
invoiceId: '', invoiceId: '',
amount: 0, amount: 0,
method: 'Cash', method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
}, },
}) })
const method = form.watch('method')
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
form.reset({ invoiceId: '', amount: 0, method: 'Cash' }) form.reset({
invoiceId: '',
amount: 0,
method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
})
}, [open]) }, [open])
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const { invoiceId, ...rest } = values const { invoiceId, method, chequeNumber, chequeDate, ...rest } = values
try { try {
await paymentsApi.create(invoiceId, rest) await paymentsApi.create(invoiceId, {
...rest,
method,
chequeNumber: method === 'Cheque' ? chequeNumber : undefined,
chequeDate: method === 'Cheque' ? chequeDate : undefined,
})
toast.add({ title: 'Payment recorded', type: 'success' }) toast.add({ title: 'Payment recorded', type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
@@ -77,7 +107,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Record Payment</DialogTitle> <DialogTitle>Record Payment</DialogTitle>
<DialogDescription>Record a cash or credit payment against an invoice.</DialogDescription> <DialogDescription>Record a cash, cheque, or credit payment against an invoice.</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -137,11 +167,71 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Cash">Cash</SelectItem> <SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Cheque">Cheque</SelectItem>
<SelectItem value="Credit">Credit</SelectItem> <SelectItem value="Credit">Credit</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
{method === 'Cheque' && (
<>
<FormField
control={form.control}
name="chequeNumber"
label="Cheque Number"
required
render={({ field, invalid }) => (
<Input
id="chequeNumber"
name={field.name}
ref={field.ref}
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 000123"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="chequeDate"
label="Cheque Date"
required
render={({ field, invalid }) => (
<Input
id="chequeDate"
name={field.name}
ref={field.ref}
type="date"
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
</>
)}
<FormField
control={form.control}
name="entryMode"
label="Entry Mode"
required
render={({ field }) => (
<ToggleGroup
variant="outline"
value={[field.value as string]}
onValueChange={(value: string[]) => value[0] && field.onChange(value[0])}
>
<ToggleGroupItem value="RealTime">Real-time</ToggleGroupItem>
<ToggleGroupItem value="DayEndManual">Day-end entry</ToggleGroupItem>
</ToggleGroup>
)}
/>
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
@@ -7,13 +7,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import type { PaymentResponse, PaymentMethod } from '@/types/api' import type { PaymentResponse, PaymentMethod, EntryMode } from '@/types/api'
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = { const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default', Cash: 'default',
Cheque: 'outline',
Credit: 'secondary', Credit: 'secondary',
} }
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
@@ -22,6 +28,9 @@ const fmtDateTime = (v: string) =>
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
}) })
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface PaymentViewSheetProps { interface PaymentViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -53,8 +62,18 @@ export function PaymentViewSheet({ open, onClose, payment, invoiceName }: Paymen
<Row label="Invoice" value={invoiceName ?? <span className="font-mono">{truncId(payment.invoiceId)}</span>} /> <Row label="Invoice" value={invoiceName ?? <span className="font-mono">{truncId(payment.invoiceId)}</span>} />
<Row label="Amount" value={fmtAmount(payment.amount)} /> <Row label="Amount" value={fmtAmount(payment.amount)} />
<Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} /> <Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} />
{payment.method === 'Cheque' && (
<>
<Row label="Cheque Number" value={payment.chequeNumber ?? '—'} />
<Row label="Cheque Date" value={payment.chequeDate ? fmtDate(payment.chequeDate) : '—'} />
</>
)}
<Row label="Entry Mode" value={<Badge variant="secondary">{ENTRY_MODE_LABEL[payment.entryMode]}</Badge>} />
<Row label="Collected At" value={fmtDateTime(payment.collectedAt)} /> <Row label="Collected At" value={fmtDateTime(payment.collectedAt)} />
<Row label="Collected By" value={<span className="font-mono">{truncId(payment.collectedBy)}</span>} /> <Row label="Collected By" value={<span className="font-mono">{truncId(payment.collectedBy)}</span>} />
{payment.recordedBy && (
<Row label="Recorded By" value={<span className="font-mono">{truncId(payment.recordedBy)}</span>} />
)}
<Row label="Created At" value={fmtDateTime(payment.createdAt)} /> <Row label="Created At" value={fmtDateTime(payment.createdAt)} />
</div> </div>
</DialogContent> </DialogContent>
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { invoices as invoicesApi, payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { invoices as invoicesApi, payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PaymentResponse, PaymentMethod, InvoiceResponse } from '@/types/api' import type { PaymentResponse, PaymentMethod, EntryMode, InvoiceResponse } from '@/types/api'
import { PaymentSheet } from './payment-sheet' import { PaymentSheet } from './payment-sheet'
import { PaymentViewSheet } from './payment-view-sheet' import { PaymentViewSheet } from './payment-view-sheet'
@@ -22,11 +22,17 @@ const fmtDate = (v: string) =>
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = { const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default', Cash: 'default',
Cheque: 'outline',
Credit: 'secondary', Credit: 'secondary',
} }
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
export function PaymentsClient() { export function PaymentsClient() {
const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([]) const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([])
const [selectedInvoiceId, setSelectedInvoiceId] = useState('') const [selectedInvoiceId, setSelectedInvoiceId] = useState('')
@@ -119,6 +125,15 @@ export function PaymentsClient() {
<Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge> <Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge>
), ),
}, },
{
key: 'entryMode',
title: 'Entry Mode',
fields: ['entryMode'],
width: 130,
cellRender: (row) => (
<Badge variant="secondary">{ENTRY_MODE_LABEL[row.entryMode]}</Badge>
),
},
{ {
key: 'collectedAt', key: 'collectedAt',
title: 'Collected At', title: 'Collected At',
@@ -143,7 +158,7 @@ export function PaymentsClient() {
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Payments</h1> <h1 className="text-2xl font-semibold text-foreground">Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Cash and credit payments collected against invoices Cash, cheque, and credit payments collected against invoices
</p> </p>
</div> </div>
<Button onClick={() => setSheetOpen(true)}> <Button onClick={() => setSheetOpen(true)}>