Files
daily_console_web/src/features/store-admin/DeliveryProgress.tsx
2026-09-22 11:13:15 +05:30

159 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { errorMessage } from '@/api/client';
import { deliveriesApi } from '@/api/deliveries';
import type { DeliveryRow } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { stampNow } from './assignDelivery';
import { isSettled, isStalled, matchesStatus } from './orderStatus';
/**
* Moving a delivery along from the back office.
*
* The rider's app drives this ladder in the ordinary case. This is for when it
* cannot: a rider whose phone died, a job cancelled by phone, a delivery that
* was completed but never marked. Without it the only way to correct a stuck
* job is a database write.
*
* ── Two things the backend does that shape this ─────────────────────────────
*
* `updatedelivery` mirrors the status onto the parent order for `pending`,
* `delivered` and `cancelled` — but NOT for `picked`, which updates the
* delivery and leaves the order where it was. So the order and its delivery
* legitimately disagree in the middle of a journey, and this does not try to
* paper over that.
*
* The lifecycle timestamp is sent explicitly. `Updates()` skips zero-valued
* struct fields, so a status sent without its stamp moved the status and left
* the column blank — which is how a delivery ends up marked delivered with no
* delivery time against it.
*/
/** What can be set from here, in the order a job actually walks. */
const MOVES = [
{ to: 'picked', label: 'Picked up', stamp: 'pickuptime' },
{ to: 'delivered', label: 'Delivered', stamp: 'deliverytime' },
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
] as const;
/** What to do about a stalled job, which is never "nothing". */
function stalledAdvice(status: string): string {
return matchesStatus('rejected', status)
? 'This rider declined the job. The order is waiting for a rider again — assign it to somebody else.'
: 'The rider could not hand this over. It is still theirs to re-attempt; reassign it if somebody else should take it.';
}
/**
* The moves, split from their rendering.
*
* The drawer puts the buttons in its fixed footer and the caption in the body,
* and those two need to share one mutation — a second instance would give the
* footer its own pending state and its own error, so a failure would be
* reported in a place the operator is not looking. Returning both from one hook
* keeps a single source of truth for what is happening.
*/
export function useDeliveryMoves(job: DeliveryRow) {
const client = useQueryClient();
const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase();
const settled = isSettled(status);
const stalled = isStalled(status);
const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
deliveriesApi.update({
deliveryid: job.deliveryid,
orderheaderid: job.orderheaderid ?? 0,
orderstatus: to,
[stamp]: stampNow(),
}),
onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.insights.all }),
onError: (error) => setProblem(errorMessage(error)),
});
return {
status,
isSettled: settled,
isStalled: stalled,
isPending: move.isPending,
problem,
moves: MOVES,
run: (to: string, stamp: string) => {
setProblem(null);
move.mutate({ to, stamp });
},
};
}
export function DeliveryProgress({ job }: { job: DeliveryRow }) {
const client = useQueryClient();
const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase();
const settled = isSettled(status);
const stalled = isStalled(status);
const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
deliveriesApi.update({
deliveryid: job.deliveryid,
orderheaderid: job.orderheaderid ?? 0,
orderstatus: to,
[stamp]: stampNow(),
}),
onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.insights.all }),
onError: (error) => setProblem(errorMessage(error)),
});
if (settled) {
return (
<Text type="body" size="xsm" color="secondary">
This job is {status}. Nothing further to record.
</Text>
);
}
if (stalled) {
return (
<Text type="body" size="xsm" color="secondary">
This job is {status}. {stalledAdvice(status)}
</Text>
);
}
return (
<VStack gap={1}>
<div className="progress-moves">
{MOVES.map((step) => (
<button
key={step.to}
type="button"
className="progress-move"
data-danger={step.to === 'cancelled'}
disabled={move.isPending || step.to === status}
onClick={() => {
setProblem(null);
move.mutate({ to: step.to, stamp: step.stamp });
}}
>
{step.label}
</button>
))}
</div>
{problem ? (
<Text type="body" size="xsm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
{problem}
</Text>
) : (
<Text type="body" size="xsm" color="secondary">
Use these only when the rider's app has not. Marking a job delivered here also closes the
order.
</Text>
)}
</VStack>
);
}