Task #69: Delete past orders from My Orders
- Add DELETE /api/orders/:id endpoint: owner can delete completed/cancelled orders; admin can delete any. 401/403/404/204 responses. Emits order_deleted to owner and assignee for live invalidation. - Update OpenAPI spec with deleteServiceOrder operation; regenerate api-client-react + api-zod. - Frontend: per-order trash button on completed/cancelled cards with confirm dialog; bulk "Clear finished (N)" button at top of list with confirm dialog and toast summary (handles partial failures). - Add ar/en locale keys under myOrders for delete + clear flows (singular/plural variants). - Backend tests cover all paths: pending owner=403, stranger=403, owner-cancelled=204, owner-completed=204, admin-pending=204, 404, 401. All 25 service-order tests pass; the 1 failing test (admin-app-opens- pagination) is unrelated/pre-existing. Follow-ups proposed: #70 undo for deleted orders, #71 audit self-notification on receiver-cancel-own-order.
This commit is contained in:
@@ -376,4 +376,59 @@ router.patch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete any
|
||||||
|
router.delete(
|
||||||
|
"/orders/:id",
|
||||||
|
requireAuth,
|
||||||
|
async (req, res): Promise<void> => {
|
||||||
|
const params = ServiceOrderIdParams.safeParse(req.params);
|
||||||
|
if (!params.success) {
|
||||||
|
res.status(400).json({ error: params.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = req.session.userId!;
|
||||||
|
const orderId = params.data.id;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(serviceOrdersTable)
|
||||||
|
.where(eq(serviceOrdersTable.id, orderId));
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Order not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await isAdmin(userId);
|
||||||
|
const isOwner = existing.userId === userId;
|
||||||
|
const isTerminal =
|
||||||
|
existing.status === "completed" || existing.status === "cancelled";
|
||||||
|
|
||||||
|
if (!admin && !(isOwner && isTerminal)) {
|
||||||
|
res.status(403).json({ error: "Forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(serviceOrdersTable)
|
||||||
|
.where(eq(serviceOrdersTable.id, orderId));
|
||||||
|
|
||||||
|
await emitToUser(existing.userId, "order_deleted", { id: orderId });
|
||||||
|
if (existing.assignedTo && existing.assignedTo !== existing.userId) {
|
||||||
|
await emitToUser(existing.assignedTo, "order_deleted", { id: orderId });
|
||||||
|
}
|
||||||
|
// If the deleted order was visible to receivers (pending or active),
|
||||||
|
// refresh their incoming queues.
|
||||||
|
if (
|
||||||
|
existing.status === "pending" ||
|
||||||
|
existing.status === "received" ||
|
||||||
|
existing.status === "preparing"
|
||||||
|
) {
|
||||||
|
const receivers = await getReceiverUserIds();
|
||||||
|
await broadcastIncomingChanged(receivers);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -297,6 +297,70 @@ test("requester gets a distinct notification when the receiver cancels a claimed
|
|||||||
assert.match(cancelNotif.body_ar, /استلم طلبك ولكن تم إلغاؤه لاحقاً/);
|
assert.match(cancelNotif.body_ar, /استلم طلبك ولكن تم إلغاؤه لاحقاً/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("DELETE /orders/:id — owner can delete completed/cancelled, admin can delete any, others 403", async () => {
|
||||||
|
const owner = await createUser("delOwn", "user");
|
||||||
|
const recv = await createUser("delRecv", "order_receiver");
|
||||||
|
const stranger = await createUser("delStr", "user");
|
||||||
|
const adminUser = await createUser("delAdm", "admin");
|
||||||
|
const co = await login(owner.username);
|
||||||
|
const cr = await login(recv.username);
|
||||||
|
const cs = await login(stranger.username);
|
||||||
|
const ca = await login(adminUser.username);
|
||||||
|
|
||||||
|
// Helper to create an order owned by `co`
|
||||||
|
const placeOrder = async () => {
|
||||||
|
const r = await call(co, "POST", "/orders", { serviceId });
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
const o = await r.json();
|
||||||
|
createdOrderIds.push(o.id);
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Pending order: owner cannot delete (not terminal)
|
||||||
|
const o1 = await placeOrder();
|
||||||
|
let res = await call(co, "DELETE", `/orders/${o1.id}`);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
|
||||||
|
// 2) Stranger cannot delete a terminal order they don't own
|
||||||
|
const o2 = await placeOrder();
|
||||||
|
res = await call(co, "PATCH", `/orders/${o2.id}/status`, { status: "cancelled" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
res = await call(cs, "DELETE", `/orders/${o2.id}`);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
|
||||||
|
// 3) Owner CAN delete a cancelled order they own
|
||||||
|
res = await call(co, "DELETE", `/orders/${o2.id}`);
|
||||||
|
assert.equal(res.status, 204);
|
||||||
|
// Confirm it's gone from /orders/my
|
||||||
|
res = await call(co, "GET", "/orders/my");
|
||||||
|
const list1 = await res.json();
|
||||||
|
assert.ok(!list1.find((o) => o.id === o2.id));
|
||||||
|
|
||||||
|
// 4) Owner CAN delete a completed order
|
||||||
|
const o3 = await placeOrder();
|
||||||
|
res = await call(cr, "PATCH", `/orders/${o3.id}/confirm-receipt`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "completed" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
res = await call(co, "DELETE", `/orders/${o3.id}`);
|
||||||
|
assert.equal(res.status, 204);
|
||||||
|
|
||||||
|
// 5) Admin CAN delete a non-terminal (pending) order
|
||||||
|
const o4 = await placeOrder();
|
||||||
|
res = await call(ca, "DELETE", `/orders/${o4.id}`);
|
||||||
|
assert.equal(res.status, 204);
|
||||||
|
|
||||||
|
// 6) 404 for unknown id
|
||||||
|
res = await call(co, "DELETE", `/orders/9999999`);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
|
||||||
|
// 7) Unauthenticated → 401
|
||||||
|
res = await fetch(`${API_BASE}/api/orders/1`, { method: "DELETE" });
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
test("unauthenticated cannot place order", async () => {
|
test("unauthenticated cannot place order", async () => {
|
||||||
const res = await fetch(`${API_BASE}/api/orders`, {
|
const res = await fetch(`${API_BASE}/api/orders`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export function useNotificationsSocket() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on("order_deleted", () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getListMyServiceOrdersQueryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("order_incoming_changed", () => {
|
socket.on("order_incoming_changed", () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||||
|
|||||||
@@ -131,7 +131,22 @@
|
|||||||
"cancelConfirm": "نعم، إلغاء",
|
"cancelConfirm": "نعم، إلغاء",
|
||||||
"keep": "احتفظ به",
|
"keep": "احتفظ به",
|
||||||
"cancelled": "تم إلغاء الطلب",
|
"cancelled": "تم إلغاء الطلب",
|
||||||
"cancelFailed": "تعذّر إلغاء الطلب"
|
"cancelFailed": "تعذّر إلغاء الطلب",
|
||||||
|
"delete": "حذف",
|
||||||
|
"deleteConfirmTitle": "حذف هذا الطلب؟",
|
||||||
|
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
|
||||||
|
"deleteConfirm": "نعم، احذف",
|
||||||
|
"deleted": "تم حذف الطلب",
|
||||||
|
"deleteFailed": "تعذّر حذف الطلب",
|
||||||
|
"clearFinished_one": "مسح طلب منتهٍ",
|
||||||
|
"clearFinished_other": "مسح {{count}} طلبات منتهية",
|
||||||
|
"clearConfirmTitle": "مسح الطلبات المنتهية؟",
|
||||||
|
"clearConfirmBody_one": "سيتم حذف طلب واحد منتهٍ نهائياً من سجلّك.",
|
||||||
|
"clearConfirmBody_other": "سيتم حذف {{count}} طلبات منتهية نهائياً من سجلّك.",
|
||||||
|
"clearConfirm": "نعم، امسح",
|
||||||
|
"clearedCount_one": "تم حذف طلب واحد",
|
||||||
|
"clearedCount_other": "تم حذف {{count}} طلبات",
|
||||||
|
"clearedPartial": "تم حذف {{ok}} وفشل {{fail}}"
|
||||||
},
|
},
|
||||||
"orderStatus": {
|
"orderStatus": {
|
||||||
"pending": "بانتظار المستلِم",
|
"pending": "بانتظار المستلِم",
|
||||||
|
|||||||
@@ -131,7 +131,22 @@
|
|||||||
"cancelConfirm": "Yes, cancel",
|
"cancelConfirm": "Yes, cancel",
|
||||||
"keep": "Keep it",
|
"keep": "Keep it",
|
||||||
"cancelled": "Order cancelled",
|
"cancelled": "Order cancelled",
|
||||||
"cancelFailed": "Could not cancel the order"
|
"cancelFailed": "Could not cancel the order",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirmTitle": "Delete this order?",
|
||||||
|
"deleteConfirmBody": "This will permanently remove the order from your history.",
|
||||||
|
"deleteConfirm": "Yes, delete",
|
||||||
|
"deleted": "Order deleted",
|
||||||
|
"deleteFailed": "Could not delete the order",
|
||||||
|
"clearFinished_one": "Clear 1 finished",
|
||||||
|
"clearFinished_other": "Clear {{count}} finished",
|
||||||
|
"clearConfirmTitle": "Clear finished orders?",
|
||||||
|
"clearConfirmBody_one": "1 finished order will be permanently removed from your history.",
|
||||||
|
"clearConfirmBody_other": "{{count}} finished orders will be permanently removed from your history.",
|
||||||
|
"clearConfirm": "Yes, clear",
|
||||||
|
"clearedCount_one": "1 order deleted",
|
||||||
|
"clearedCount_other": "{{count}} orders deleted",
|
||||||
|
"clearedPartial": "{{ok}} deleted, {{fail}} failed"
|
||||||
},
|
},
|
||||||
"orderStatus": {
|
"orderStatus": {
|
||||||
"pending": "Awaiting receiver",
|
"pending": "Awaiting receiver",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useListMyServiceOrders,
|
useListMyServiceOrders,
|
||||||
getListMyServiceOrdersQueryKey,
|
getListMyServiceOrdersQueryKey,
|
||||||
useUpdateServiceOrderStatus,
|
useUpdateServiceOrderStatus,
|
||||||
|
useDeleteServiceOrder,
|
||||||
type ServiceOrder,
|
type ServiceOrder,
|
||||||
type ServiceOrderStatus,
|
type ServiceOrderStatus,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +53,10 @@ const STATUS_PILL: Record<ServiceOrderStatus, string> = {
|
|||||||
cancelled: "bg-rose-100 text-rose-700",
|
cancelled: "bg-rose-100 text-rose-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isFinished(status: ServiceOrderStatus) {
|
||||||
|
return status === "completed" || status === "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
function StatusPill({ status }: { status: ServiceOrderStatus }) {
|
function StatusPill({ status }: { status: ServiceOrderStatus }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -149,13 +155,16 @@ function OrderCard({ order }: { order: ServiceOrder }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||||
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
const updateStatus = useUpdateServiceOrderStatus();
|
const updateStatus = useUpdateServiceOrderStatus();
|
||||||
|
const deleteOrder = useDeleteServiceOrder();
|
||||||
|
|
||||||
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
|
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
|
||||||
const img = resolveServiceImageUrl(order.service.imageUrl);
|
const img = resolveServiceImageUrl(order.service.imageUrl);
|
||||||
const cancellable =
|
const cancellable =
|
||||||
order.status === "pending" || order.status === "received";
|
order.status === "pending" || order.status === "received";
|
||||||
|
const deletable = isFinished(order.status);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
updateStatus.mutate(
|
updateStatus.mutate(
|
||||||
@@ -166,7 +175,7 @@ function OrderCard({ order }: { order: ServiceOrder }) {
|
|||||||
queryKey: getListMyServiceOrdersQueryKey(),
|
queryKey: getListMyServiceOrdersQueryKey(),
|
||||||
});
|
});
|
||||||
toast({ title: t("myOrders.cancelled") });
|
toast({ title: t("myOrders.cancelled") });
|
||||||
setConfirmOpen(false);
|
setConfirmCancelOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({
|
toast({
|
||||||
@@ -178,6 +187,27 @@ 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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
|
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -205,21 +235,36 @@ function OrderCard({ order }: { order: ServiceOrder }) {
|
|||||||
|
|
||||||
<StatusTimeline status={order.status} />
|
<StatusTimeline status={order.status} />
|
||||||
|
|
||||||
{cancellable && (
|
{(cancellable || deletable) && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-1">
|
||||||
<Button
|
{cancellable && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
|
size="sm"
|
||||||
onClick={() => setConfirmOpen(true)}
|
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
|
||||||
disabled={updateStatus.isPending}
|
onClick={() => setConfirmCancelOpen(true)}
|
||||||
>
|
disabled={updateStatus.isPending}
|
||||||
{t("myOrders.cancel")}
|
>
|
||||||
</Button>
|
{t("myOrders.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{deletable && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
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")}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<AlertDialog open={confirmCancelOpen} onOpenChange={setConfirmCancelOpen}>
|
||||||
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
|
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
@@ -246,6 +291,34 @@ function OrderCard({ order }: { order: ServiceOrder }) {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||||
|
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("myOrders.deleteConfirmTitle")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("myOrders.deleteConfirmBody")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteOrder.isPending}>
|
||||||
|
{t("myOrders.keep")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
disabled={deleteOrder.isPending}
|
||||||
|
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||||
|
>
|
||||||
|
{t("myOrders.deleteConfirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,9 +326,14 @@ function OrderCard({ order }: { order: ServiceOrder }) {
|
|||||||
export default function MyOrdersPage() {
|
export default function MyOrdersPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const isRtl = lang === "ar";
|
const isRtl = lang === "ar";
|
||||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||||
|
const [confirmClearOpen, setConfirmClearOpen] = useState(false);
|
||||||
|
const deleteOrder = useDeleteServiceOrder();
|
||||||
|
const [bulkPending, setBulkPending] = useState(false);
|
||||||
|
|
||||||
const { data: orders, isLoading, isError, refetch } = useListMyServiceOrders({
|
const { data: orders, isLoading, isError, refetch } = useListMyServiceOrders({
|
||||||
query: { queryKey: getListMyServiceOrdersQueryKey() },
|
query: { queryKey: getListMyServiceOrdersQueryKey() },
|
||||||
@@ -268,6 +346,36 @@ export default function MyOrdersPage() {
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const finishedOrders = sortedOrders.filter((o) => isFinished(o.status));
|
||||||
|
const finishedCount = finishedOrders.length;
|
||||||
|
|
||||||
|
const handleClearFinished = async () => {
|
||||||
|
setBulkPending(true);
|
||||||
|
let okCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
for (const o of finishedOrders) {
|
||||||
|
try {
|
||||||
|
await deleteOrder.mutateAsync({ id: o.id });
|
||||||
|
okCount += 1;
|
||||||
|
} catch {
|
||||||
|
failCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen os-bg flex flex-col">
|
<div className="min-h-screen os-bg flex flex-col">
|
||||||
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
|
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
|
||||||
@@ -319,12 +427,54 @@ export default function MyOrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{finishedCount > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
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 })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{sortedOrders.map((o) => (
|
{sortedOrders.map((o) => (
|
||||||
<OrderCard key={o.id} order={o} />
|
<OrderCard key={o.id} order={o} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmClearOpen} onOpenChange={setConfirmClearOpen}>
|
||||||
|
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("myOrders.clearConfirmTitle")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("myOrders.clearConfirmBody", { count: finishedCount })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={bulkPending}>
|
||||||
|
{t("myOrders.keep")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClearFinished();
|
||||||
|
}}
|
||||||
|
disabled={bulkPending}
|
||||||
|
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||||
|
>
|
||||||
|
{t("myOrders.clearConfirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4379,6 +4379,94 @@ export const useDeleteUser = <
|
|||||||
return useMutation(getDeleteUserMutationOptions(options));
|
return useMutation(getDeleteUserMutationOptions(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently deletes the order. Allowed when the caller owns the order
|
||||||
|
AND it is in a terminal status (completed/cancelled), OR the caller is
|
||||||
|
an admin (any status).
|
||||||
|
|
||||||
|
* @summary Delete a service order
|
||||||
|
*/
|
||||||
|
export const getDeleteServiceOrderUrl = (id: number) => {
|
||||||
|
return `/api/orders/${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteServiceOrder = async (
|
||||||
|
id: number,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<void> => {
|
||||||
|
return customFetch<void>(getDeleteServiceOrderUrl(id), {
|
||||||
|
...options,
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDeleteServiceOrderMutationOptions = <
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof deleteServiceOrder>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof deleteServiceOrder>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["deleteServiceOrder"];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof deleteServiceOrder>>,
|
||||||
|
{ id: number }
|
||||||
|
> = (props) => {
|
||||||
|
const { id } = props ?? {};
|
||||||
|
|
||||||
|
return deleteServiceOrder(id, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteServiceOrderMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof deleteServiceOrder>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DeleteServiceOrderMutationError = ErrorType<ErrorResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete a service order
|
||||||
|
*/
|
||||||
|
export const useDeleteServiceOrder = <
|
||||||
|
TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof deleteServiceOrder>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof deleteServiceOrder>>,
|
||||||
|
TError,
|
||||||
|
{ id: number },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getDeleteServiceOrderMutationOptions(options));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Idempotently add a role to a user (admin)
|
* @summary Idempotently add a role to a user (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1125,6 +1125,37 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/orders/{id}:
|
||||||
|
delete:
|
||||||
|
operationId: deleteServiceOrder
|
||||||
|
tags: [orders]
|
||||||
|
summary: Delete a service order
|
||||||
|
description: |
|
||||||
|
Permanently deletes the order. Allowed when the caller owns the order
|
||||||
|
AND it is in a terminal status (completed/cancelled), OR the caller is
|
||||||
|
an admin (any status).
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Deleted
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: Order not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/users/{id}/roles:
|
/users/{id}/roles:
|
||||||
post:
|
post:
|
||||||
operationId: addUserRole
|
operationId: addUserRole
|
||||||
|
|||||||
@@ -1405,6 +1405,17 @@ export const DeleteUserParams = zod.object({
|
|||||||
id: zod.coerce.number(),
|
id: zod.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently deletes the order. Allowed when the caller owns the order
|
||||||
|
AND it is in a terminal status (completed/cancelled), OR the caller is
|
||||||
|
an admin (any status).
|
||||||
|
|
||||||
|
* @summary Delete a service order
|
||||||
|
*/
|
||||||
|
export const DeleteServiceOrderParams = zod.object({
|
||||||
|
id: zod.coerce.number(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Idempotently add a role to a user (admin)
|
* @summary Idempotently add a role to a user (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user