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:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -138,6 +138,8 @@
|
||||
"deleteConfirm": "نعم، احذف",
|
||||
"deleted": "تم حذف الطلب",
|
||||
"deleteFailed": "تعذّر حذف الطلب",
|
||||
"undo": "تراجع",
|
||||
"undone": "تمت الاستعادة",
|
||||
"clearFinished_one": "مسح طلب منتهٍ",
|
||||
"clearFinished_other": "مسح {{count}} طلبات منتهية",
|
||||
"clearConfirmTitle": "مسح الطلبات المنتهية؟",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
setConfirmDeleteOpen(false);
|
||||
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 {
|
||||
toast({
|
||||
title: t("myOrders.clearedPartial", { ok: okCount, fail: failCount }),
|
||||
variant: "destructive",
|
||||
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.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")}
|
||||
|
||||
Reference in New Issue
Block a user