Add undo window for deleted orders on My Orders

Task #70: After deleting a finished order from My Orders, users had no way
to recover it. This change introduces a short undo window so accidental
deletes can be reversed while keeping the cleanup workflow snappy.

Approach:
- Implemented as a client-side deferred-delete in
  artifacts/teaboy-os/src/pages/my-orders.tsx. No backend changes.
- On delete (single trash icon or bulk "Clear N finished"):
  * The order(s) are added to a local pendingDeleteIds set so they
    immediately disappear from the list.
  * A toast is shown with a localised "Undo" action and a 7s duration.
  * A setTimeout is scheduled to call DELETE /api/orders/:id for the
    affected ids and then invalidate the my-orders query.
- Clicking "Undo" cancels the timer, removes the ids from
  pendingDeleteIds (so the cards reappear), dismisses the toast, and
  shows a "Restored" confirmation toast.
- On unmount, any still-pending deletions are flushed so they aren't
  silently lost if the user navigates away mid-window.
- If the deferred DELETE later fails, the ids are restored to the list
  and a "Could not delete the order" destructive toast is shown.

OrderCard now delegates the actual delete to a new onDelete callback
from the page (the confirmation dialog stays in the card). Bulk clear
no longer fires N sequential mutations from inside a confirm dialog
spinner — it just schedules them.

i18n: added myOrders.undo / myOrders.undone strings to en.json and
ar.json.

Verification:
- pnpm tsc --noEmit passes for teaboy-os.
- e2e (runTest) covered: single delete + undo restores; single delete
  with no undo becomes permanent after 7s; bulk clear + undo restores
  all. DB state was asserted in each case.

Follow-up proposed: #72 — apply the same undo pattern to cancelled
orders.

Replit-Task-Id: 1854a0b1-f6d3-4148-b2c2-b0940202d0df
This commit is contained in:
riyadhafraa
2026-04-22 06:46:42 +00:00
parent 15b32d5ac5
commit d2c8e05c6c
4 changed files with 135 additions and 52 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2
View File
@@ -138,6 +138,8 @@
"deleteConfirm": "نعم، احذف",
"deleted": "تم حذف الطلب",
"deleteFailed": "تعذّر حذف الطلب",
"undo": "تراجع",
"undone": "تمت الاستعادة",
"clearFinished_one": "مسح طلب منتهٍ",
"clearFinished_other": "مسح {{count}} طلبات منتهية",
"clearConfirmTitle": "مسح الطلبات المنتهية؟",
+2
View File
@@ -138,6 +138,8 @@
"deleteConfirm": "Yes, delete",
"deleted": "Order deleted",
"deleteFailed": "Could not delete the order",
"undo": "Undo",
"undone": "Restored",
"clearFinished_one": "Clear 1 finished",
"clearFinished_other": "Clear {{count}} finished",
"clearConfirmTitle": "Clear finished orders?",
+127 -48
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
@@ -33,6 +33,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ToastAction } from "@/components/ui/toast";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { useToast } from "@/hooks/use-toast";
import { formatDateTime } from "@/lib/i18n-format";
@@ -53,6 +54,8 @@ const STATUS_PILL: Record<ServiceOrderStatus, string> = {
cancelled: "bg-rose-100 text-rose-700",
};
const UNDO_WINDOW_MS = 7000;
function isFinished(status: ServiceOrderStatus) {
return status === "completed" || status === "cancelled";
}
@@ -150,7 +153,13 @@ function OrderImage({
);
}
function OrderCard({ order }: { order: ServiceOrder }) {
function OrderCard({
order,
onDelete,
}: {
order: ServiceOrder;
onDelete: (id: number) => void;
}) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
@@ -158,7 +167,6 @@ function OrderCard({ order }: { order: ServiceOrder }) {
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const updateStatus = useUpdateServiceOrderStatus();
const deleteOrder = useDeleteServiceOrder();
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
const img = resolveServiceImageUrl(order.service.imageUrl);
@@ -188,24 +196,8 @@ function OrderCard({ order }: { order: ServiceOrder }) {
};
const handleDelete = () => {
deleteOrder.mutate(
{ id: order.id },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
toast({ title: t("myOrders.deleted") });
setConfirmDeleteOpen(false);
},
onError: () => {
toast({
title: t("myOrders.deleteFailed"),
variant: "destructive",
});
},
},
);
onDelete(order.id);
};
return (
@@ -254,7 +246,6 @@ function OrderCard({ order }: { order: ServiceOrder }) {
size="sm"
className="text-slate-500 hover:text-rose-700 hover:bg-rose-50"
onClick={() => setConfirmDeleteOpen(true)}
disabled={deleteOrder.isPending}
aria-label={t("myOrders.delete")}
title={t("myOrders.delete")}
>
@@ -303,7 +294,7 @@ function OrderCard({ order }: { order: ServiceOrder }) {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteOrder.isPending}>
<AlertDialogCancel>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
@@ -311,7 +302,6 @@ function OrderCard({ order }: { order: ServiceOrder }) {
e.preventDefault();
handleDelete();
}}
disabled={deleteOrder.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.deleteConfirm")}
@@ -333,29 +323,25 @@ export default function MyOrdersPage() {
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const [confirmClearOpen, setConfirmClearOpen] = useState(false);
const deleteOrder = useDeleteServiceOrder();
const [bulkPending, setBulkPending] = useState(false);
const [pendingDeleteIds, setPendingDeleteIds] = useState<Set<number>>(
() => new Set(),
);
// Track scheduled timers and queued IDs so we can flush on unmount.
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
new Map(),
);
const { data: orders, isLoading, isError, refetch } = useListMyServiceOrders({
query: { queryKey: getListMyServiceOrdersQueryKey() },
});
const sortedOrders = orders
? [...orders].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
: [];
const finishedOrders = sortedOrders.filter((o) => isFinished(o.status));
const finishedCount = finishedOrders.length;
const handleClearFinished = async () => {
setBulkPending(true);
const performDelete = async (ids: number[]) => {
let okCount = 0;
let failCount = 0;
for (const o of finishedOrders) {
for (const id of ids) {
try {
await deleteOrder.mutateAsync({ id: o.id });
await deleteOrder.mutateAsync({ id });
okCount += 1;
} catch {
failCount += 1;
@@ -364,16 +350,107 @@ export default function MyOrdersPage() {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
setBulkPending(false);
setConfirmClearOpen(false);
if (failCount === 0) {
toast({ title: t("myOrders.clearedCount", { count: okCount }) });
} else {
return { okCount, failCount };
};
// On unmount, flush any pending deletions so they aren't silently lost.
useEffect(() => {
return () => {
const pending: number[] = [];
timersRef.current.forEach((timer, id) => {
clearTimeout(timer);
pending.push(id);
});
timersRef.current.clear();
if (pending.length > 0) {
// Fire-and-forget; the page is unmounting.
void performDelete(pending);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const scheduleDelete = (ids: number[]) => {
if (ids.length === 0) return;
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.add(id));
return next;
});
const timer = setTimeout(() => {
ids.forEach((id) => timersRef.current.delete(id));
void performDelete(ids).then(({ failCount }) => {
if (failCount > 0) {
// Restore failed deletions on error so the user sees them again.
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
toast({
title: t("myOrders.clearedPartial", { ok: okCount, fail: failCount }),
title: t("myOrders.deleteFailed"),
variant: "destructive",
});
} else {
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
}
});
}, UNDO_WINDOW_MS);
ids.forEach((id) => timersRef.current.set(id, timer));
const undo = () => {
ids.forEach((id) => {
const existing = timersRef.current.get(id);
if (existing) clearTimeout(existing);
timersRef.current.delete(id);
});
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
handle.dismiss();
toast({ title: t("myOrders.undone") });
};
const title =
ids.length === 1
? t("myOrders.deleted")
: t("myOrders.clearedCount", { count: ids.length });
const handle = toast({
title,
duration: UNDO_WINDOW_MS,
action: (
<ToastAction altText={t("myOrders.undo")} onClick={undo}>
{t("myOrders.undo")}
</ToastAction>
),
});
};
const sortedOrders = orders
? [...orders]
.filter((o) => !pendingDeleteIds.has(o.id))
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
: [];
const finishedOrders = sortedOrders.filter((o) => isFinished(o.status));
const finishedCount = finishedOrders.length;
const handleClearFinished = () => {
setConfirmClearOpen(false);
scheduleDelete(finishedOrders.map((o) => o.id));
};
return (
@@ -434,7 +511,6 @@ export default function MyOrdersPage() {
size="sm"
className="text-slate-600 hover:text-rose-700 hover:bg-rose-50 gap-1.5"
onClick={() => setConfirmClearOpen(true)}
disabled={bulkPending}
>
<Trash2 size={14} />
{t("myOrders.clearFinished", { count: finishedCount })}
@@ -442,7 +518,11 @@ export default function MyOrdersPage() {
</div>
)}
{sortedOrders.map((o) => (
<OrderCard key={o.id} order={o} />
<OrderCard
key={o.id}
order={o}
onDelete={(id) => scheduleDelete([id])}
/>
))}
</div>
)}
@@ -459,7 +539,7 @@ export default function MyOrdersPage() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkPending}>
<AlertDialogCancel>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
@@ -467,7 +547,6 @@ export default function MyOrdersPage() {
e.preventDefault();
handleClearFinished();
}}
disabled={bulkPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.clearConfirm")}