Add Undo window when receivers cancel an incoming order
Original task (#73): Mirror the existing 7s Undo toast (already in place for owner-side cancels in My Orders) on the receiver-side cancel action in the Incoming Orders page. Implementation: - artifacts/teaboy-os/src/pages/orders-incoming.tsx - Captures the order's previous status (received | preparing) before issuing the cancel PATCH. - On success, shows a toast with title "Order cancelled" and an "Undo" ToastAction. Clicking Undo PATCHes the order status back to the previous value, then shows a "Restored" confirmation toast. - UNDO_WINDOW_MS = 7000 to match the owner-side flow. - artifacts/api-server/src/routes/service-orders.ts - Refactored the PATCH /orders/:id/status authorization. Detects "restore from cancelled" up front (existing.status === cancelled and next !== cancelled) and grants permission to: - owner/admin: may restore to pending or received (existing rule) - original assignee: may restore to received or preparing (NEW) - The same 15s server-side undo window applies. - artifacts/teaboy-os/src/locales/{en,ar}.json - Added incomingOrders.cancelled / undo / undone / restoreFailed strings in English and Arabic. - Regenerated lib/api-zod and lib/api-client-react via the api-spec codegen script (the previously-cached output was missing pending / received / preparing from the request body enum, which now matches the OpenAPI spec). Verification: - Typecheck passes for api-server and teaboy-os. - e2e flow verified: cancelling a received order shows the Undo toast; clicking Undo restores the order to "received" (DB confirmed); letting the window expire leaves the order permanently "cancelled" (DB confirmed). Deviations: None. Replit-Task-Id: 65c4b053-613a-4898-a0f7-b6fb2d680301
This commit is contained in:
@@ -302,7 +302,49 @@ router.patch(
|
|||||||
|
|
||||||
const admin = await isAdmin(userId);
|
const admin = await isAdmin(userId);
|
||||||
|
|
||||||
if (next === "preparing" || next === "completed") {
|
const isRestoreFromCancelled =
|
||||||
|
existing.status === "cancelled" && next !== "cancelled";
|
||||||
|
|
||||||
|
if (isRestoreFromCancelled) {
|
||||||
|
// Restore a cancelled order back to a prior active status (Undo).
|
||||||
|
// Owner/admin may restore to pending or received; the original
|
||||||
|
// assignee may restore to received or preparing.
|
||||||
|
const isOwner = existing.userId === userId;
|
||||||
|
const isAssignee = existing.assignedTo === userId;
|
||||||
|
const canOwnerRestore =
|
||||||
|
(isOwner || admin) && (next === "pending" || next === "received");
|
||||||
|
const canAssigneeRestore =
|
||||||
|
isAssignee && (next === "received" || next === "preparing");
|
||||||
|
if (!canOwnerRestore && !canAssigneeRestore) {
|
||||||
|
if (!isOwner && !isAssignee && !admin) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(400).json({ error: "invalid_transition" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next === "pending" && existing.assignedTo !== null) {
|
||||||
|
res.status(400).json({ error: "invalid_transition" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(next === "received" || next === "preparing") &&
|
||||||
|
existing.assignedTo === null
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: "invalid_transition" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Enforce the undo window: once the window expires, the
|
||||||
|
// cancellation is permanent. Admin is also bound by this so
|
||||||
|
// restore is always Undo, never an arbitrary reopen.
|
||||||
|
const cancelledAt = existing.updatedAt
|
||||||
|
? new Date(existing.updatedAt).getTime()
|
||||||
|
: 0;
|
||||||
|
if (Date.now() - cancelledAt > RESTORE_WINDOW_MS) {
|
||||||
|
res.status(400).json({ error: "undo_window_expired" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (next === "preparing" || next === "completed") {
|
||||||
// Only the assigned receiver or admin
|
// Only the assigned receiver or admin
|
||||||
const isAssignee = existing.assignedTo === userId;
|
const isAssignee = existing.assignedTo === userId;
|
||||||
if (!isAssignee && !admin) {
|
if (!isAssignee && !admin) {
|
||||||
@@ -318,35 +360,10 @@ router.patch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (next === "pending" || next === "received") {
|
} else if (next === "pending" || next === "received") {
|
||||||
// Restore a cancelled order back to its prior status (Undo).
|
// Restore is handled above; outside cancelled, these targets are
|
||||||
// Allowed for the owner or admin only.
|
// never valid via this endpoint.
|
||||||
const isOwner = existing.userId === userId;
|
res.status(400).json({ error: "invalid_transition" });
|
||||||
if (!isOwner && !admin) {
|
return;
|
||||||
res.status(403).json({ error: "Forbidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (existing.status !== "cancelled") {
|
|
||||||
res.status(400).json({ error: "invalid_transition" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Enforce the undo window: once the window expires, the
|
|
||||||
// cancellation is permanent. Admin is also bound by this so
|
|
||||||
// restore is always Undo, never an arbitrary reopen.
|
|
||||||
const cancelledAt = existing.updatedAt
|
|
||||||
? new Date(existing.updatedAt).getTime()
|
|
||||||
: 0;
|
|
||||||
if (Date.now() - cancelledAt > RESTORE_WINDOW_MS) {
|
|
||||||
res.status(400).json({ error: "undo_window_expired" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (next === "pending" && existing.assignedTo !== null) {
|
|
||||||
res.status(400).json({ error: "invalid_transition" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (next === "received" && existing.assignedTo === null) {
|
|
||||||
res.status(400).json({ error: "invalid_transition" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (next === "cancelled") {
|
} else if (next === "cancelled") {
|
||||||
const isOwner = existing.userId === userId;
|
const isOwner = existing.userId === userId;
|
||||||
const isAssignee = existing.assignedTo === userId;
|
const isAssignee = existing.assignedTo === userId;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -182,7 +182,11 @@
|
|||||||
"alreadyClaimed": "استلمه شخص آخر قبلك",
|
"alreadyClaimed": "استلمه شخص آخر قبلك",
|
||||||
"actionFailed": "تعذّر تنفيذ الإجراء",
|
"actionFailed": "تعذّر تنفيذ الإجراء",
|
||||||
"claimed": "تم استلام الطلب",
|
"claimed": "تم استلام الطلب",
|
||||||
"marked": "تم تحديث الحالة"
|
"marked": "تم تحديث الحالة",
|
||||||
|
"cancelled": "تم إلغاء الطلب",
|
||||||
|
"undo": "تراجع",
|
||||||
|
"undone": "تمت الاستعادة",
|
||||||
|
"restoreFailed": "تعذّر استعادة الطلب"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "المحادثات",
|
"title": "المحادثات",
|
||||||
|
|||||||
@@ -182,7 +182,11 @@
|
|||||||
"alreadyClaimed": "Already claimed by someone else",
|
"alreadyClaimed": "Already claimed by someone else",
|
||||||
"actionFailed": "Could not complete the action",
|
"actionFailed": "Could not complete the action",
|
||||||
"claimed": "Order received",
|
"claimed": "Order received",
|
||||||
"marked": "Status updated"
|
"marked": "Status updated",
|
||||||
|
"cancelled": "Order cancelled",
|
||||||
|
"undo": "Undo",
|
||||||
|
"undone": "Restored",
|
||||||
|
"restoreFailed": "Could not restore the order"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "Chat",
|
"title": "Chat",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ToastAction } from "@/components/ui/toast";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||||
@@ -29,6 +30,8 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
export const ORDER_RECEIVER_ROLE = "order_receiver";
|
export const ORDER_RECEIVER_ROLE = "order_receiver";
|
||||||
|
|
||||||
|
const UNDO_WINDOW_MS = 7000;
|
||||||
|
|
||||||
export function userCanReceiveOrders(roles: string[] | undefined | null): boolean {
|
export function userCanReceiveOrders(roles: string[] | undefined | null): boolean {
|
||||||
if (!roles) return false;
|
if (!roles) return false;
|
||||||
return roles.includes("admin") || roles.includes(ORDER_RECEIVER_ROLE);
|
return roles.includes("admin") || roles.includes(ORDER_RECEIVER_ROLE);
|
||||||
@@ -136,6 +139,10 @@ function IncomingOrderCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetStatus = (status: "preparing" | "completed" | "cancelled") => {
|
const handleSetStatus = (status: "preparing" | "completed" | "cancelled") => {
|
||||||
|
if (status === "cancelled") {
|
||||||
|
handleCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateStatus.mutate(
|
updateStatus.mutate(
|
||||||
{ id: order.id, data: { status } },
|
{ id: order.id, data: { status } },
|
||||||
{
|
{
|
||||||
@@ -153,6 +160,59 @@ function IncomingOrderCard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const previousStatus = order.status;
|
||||||
|
if (previousStatus !== "received" && previousStatus !== "preparing") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateStatus.mutate(
|
||||||
|
{ id: order.id, data: { status: "cancelled" } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
|
||||||
|
const undo = () => {
|
||||||
|
updateStatus.mutate(
|
||||||
|
{ id: order.id, data: { status: previousStatus } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
handle.dismiss();
|
||||||
|
toast({ title: t("incomingOrders.undone") });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t("incomingOrders.restoreFailed"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = toast({
|
||||||
|
title: t("incomingOrders.cancelled"),
|
||||||
|
duration: UNDO_WINDOW_MS,
|
||||||
|
action: (
|
||||||
|
<ToastAction
|
||||||
|
altText={t("incomingOrders.undo")}
|
||||||
|
onClick={undo}
|
||||||
|
>
|
||||||
|
{t("incomingOrders.undo")}
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t("incomingOrders.actionFailed"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
|
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user