Add cheque to payment UI
This commit is contained in:
@@ -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,10 +26,22 @@ 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)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user