Task #397: Per-card and bulk select+delete on Incoming Orders
Backend (artifacts/api-server):
- Added hasReceivePermission() helper and refactored DELETE /orders/:id
into shared authorizeAndDeleteOrder() so single and bulk paths use the
same authorization rules.
- Added POST /orders/bulk-delete returning { deletedIds, failedIds } with
per-id authorization, dedup, and best-effort partial success.
- Tightened receiver authorization (per code review): receivers may only
delete pending/received/preparing orders; terminal (completed/cancelled)
orders remain the owner's cleanup responsibility. OpenAPI description
and the implementation now agree.
API spec (lib/api-spec/openapi.yaml):
- New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and
BulkDeleteServiceOrdersResponse schemas; updated DELETE /orders/{id}
description. Regenerated react-query hooks via codegen — useBulkDelete
ServiceOrders is now available.
UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Selection mode toggle in the page header (RTL-safe).
- Per-card Checkbox plus a Select-all control.
- Sticky bottom action bar with destructive Delete and selected count.
- AlertDialog confirmation using pluralized i18n keys; toast surfaces
partial-failure results when some ids couldn't be deleted.
i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization.
Tests: Added two new tests in service-orders.test.mjs covering receiver
delete on incoming queue, receiver forbidden on terminal orders, and
bulk-delete with mixed authorized / unknown / dedup / unauthenticated
cases. Both pass. Pre-existing executive-meetings PDF/font test failures
are unrelated.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
CreateServiceOrderBody,
|
||||
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
|
||||
UpdateServiceOrderStatusBody,
|
||||
BulkDeleteServiceOrdersBody,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -91,6 +92,17 @@ async function isAdmin(userId: number): Promise<boolean> {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function hasReceivePermission(userId: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: permissionsTable.id })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
||||
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
||||
.where(and(eq(userRolesTable.userId, userId), eq(permissionsTable.name, PERM_RECEIVE)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function emitToUser(userId: number, event: string, payload?: unknown) {
|
||||
const { io } = await import("../index.js");
|
||||
io.to(`user:${userId}`).emit(event, payload);
|
||||
@@ -460,7 +472,69 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete any
|
||||
// Authorization + deletion for a single order, shared between the
|
||||
// per-id DELETE and the bulk-delete endpoint. Returns a discriminated
|
||||
// result the callers can map to HTTP status codes.
|
||||
type DeleteOutcome =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "not_found" | "forbidden" };
|
||||
|
||||
async function authorizeAndDeleteOrder(
|
||||
orderId: number,
|
||||
actorId: number,
|
||||
flags: { admin: boolean; receiver: boolean },
|
||||
): Promise<DeleteOutcome> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.id, orderId));
|
||||
if (!existing) return { ok: false, reason: "not_found" };
|
||||
|
||||
const isOwner = existing.userId === actorId;
|
||||
const isTerminal =
|
||||
existing.status === "completed" || existing.status === "cancelled";
|
||||
// Receivers can only delete orders that appear in the incoming queue.
|
||||
// Terminal orders (completed/cancelled) are not theirs to clean up.
|
||||
const isIncomingQueue =
|
||||
existing.status === "pending" ||
|
||||
existing.status === "received" ||
|
||||
existing.status === "preparing";
|
||||
|
||||
// Allowed when:
|
||||
// - admin (any order, any status)
|
||||
// - owner of a terminal order (completed/cancelled)
|
||||
// - holder of orders.receive permission, but only for orders currently in
|
||||
// the incoming queue (pending/received/preparing) — matches the page
|
||||
// they can see and prevents privilege escalation against terminal orders.
|
||||
const allowed =
|
||||
flags.admin ||
|
||||
(isOwner && isTerminal) ||
|
||||
(flags.receiver && isIncomingQueue);
|
||||
if (!allowed) return { ok: false, reason: "forbidden" };
|
||||
|
||||
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);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete
|
||||
// any; users with `orders.receive` can delete any incoming-queue order.
|
||||
router.delete(
|
||||
"/orders/:id",
|
||||
requireAuth,
|
||||
@@ -471,48 +545,61 @@ router.delete(
|
||||
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" });
|
||||
const [admin, receiver] = await Promise.all([
|
||||
isAdmin(userId),
|
||||
hasReceivePermission(userId),
|
||||
]);
|
||||
const result = await authorizeAndDeleteOrder(params.data.id, userId, {
|
||||
admin,
|
||||
receiver,
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (result.reason === "not_found") {
|
||||
res.status(404).json({ error: "Order not found" });
|
||||
} else {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/orders/bulk-delete — delete several orders in one round-trip.
|
||||
// Authorization is enforced **per id** using the same rules as the per-id
|
||||
// DELETE — there is no batching shortcut. Returns a summary the UI can use
|
||||
// to surface partial failures.
|
||||
router.post(
|
||||
"/orders/bulk-delete",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const parsed = BulkDeleteServiceOrdersBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
// Dedupe ids so we don't double-emit on accidental repeats.
|
||||
const ids = Array.from(new Set(parsed.data.ids));
|
||||
|
||||
const [admin, receiver] = await Promise.all([
|
||||
isAdmin(userId),
|
||||
hasReceivePermission(userId),
|
||||
]);
|
||||
|
||||
const deletedIds: number[] = [];
|
||||
const failedIds: number[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await authorizeAndDeleteOrder(id, userId, {
|
||||
admin,
|
||||
receiver,
|
||||
});
|
||||
if (result.ok) deletedIds.push(id);
|
||||
else failedIds.push(id);
|
||||
}
|
||||
|
||||
res.json({ deletedIds, failedIds });
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -364,6 +364,130 @@ test("DELETE /orders/:id — owner can delete completed/cancelled, admin can del
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("DELETE /orders/:id — receivers can delete any incoming-queue order (pending/received/preparing); non-receiver, non-owner still 403", async () => {
|
||||
const owner = await createUser("recvDelOwn", "user");
|
||||
const recv = await createUser("recvDelRecv", "order_receiver");
|
||||
const stranger = await createUser("recvDelStr", "user");
|
||||
const co = await login(owner.username);
|
||||
const cr = await login(recv.username);
|
||||
const cs = await login(stranger.username);
|
||||
|
||||
const place = 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) Receiver can delete a pending, unclaimed order.
|
||||
const o1 = await place();
|
||||
let res = await call(cr, "DELETE", `/orders/${o1.id}`);
|
||||
assert.equal(res.status, 204, "receiver should be able to delete pending order");
|
||||
|
||||
// 2) Receiver can delete a received order (claimed by them).
|
||||
const o2 = await place();
|
||||
res = await call(cr, "PATCH", `/orders/${o2.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "DELETE", `/orders/${o2.id}`);
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
// 3) Receiver can delete a preparing order assigned to a different receiver.
|
||||
const otherRecv = await createUser("recvDelOther", "order_receiver");
|
||||
const cor = await login(otherRecv.username);
|
||||
const o3 = await place();
|
||||
res = await call(cor, "PATCH", `/orders/${o3.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cor, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" });
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "DELETE", `/orders/${o3.id}`);
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
// 4) Non-receiver, non-owner still gets 403 on any state.
|
||||
const o4 = await place();
|
||||
res = await call(cs, "DELETE", `/orders/${o4.id}`);
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
// 5) Receiver may NOT delete a terminal (completed/cancelled) order — that
|
||||
// is the owner's cleanup responsibility, not the receiver's queue.
|
||||
const o5 = await place();
|
||||
res = await call(cr, "PATCH", `/orders/${o5.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "PATCH", `/orders/${o5.id}/status`, { status: "preparing" });
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "PATCH", `/orders/${o5.id}/status`, { status: "completed" });
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "DELETE", `/orders/${o5.id}`);
|
||||
assert.equal(res.status, 403, "receiver should NOT delete completed orders");
|
||||
|
||||
const o6 = await place();
|
||||
res = await call(co, "PATCH", `/orders/${o6.id}/status`, { status: "cancelled" });
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "DELETE", `/orders/${o6.id}`);
|
||||
assert.equal(res.status, 403, "receiver should NOT delete cancelled orders");
|
||||
});
|
||||
|
||||
test("POST /orders/bulk-delete — deletes authorized ids, reports failures, dedupes", async () => {
|
||||
const owner = await createUser("bulkOwn", "user");
|
||||
const recv = await createUser("bulkRecv", "order_receiver");
|
||||
const stranger = await createUser("bulkStr", "user");
|
||||
const co = await login(owner.username);
|
||||
const cr = await login(recv.username);
|
||||
const cs = await login(stranger.username);
|
||||
|
||||
const place = 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;
|
||||
};
|
||||
|
||||
// Receiver deletes 3 ids; one is unknown → reported as failed; ids deduped.
|
||||
const a = await place();
|
||||
const b = await place();
|
||||
const c = await place();
|
||||
const unknownId = 99999991;
|
||||
|
||||
let res = await call(cr, "POST", "/orders/bulk-delete", {
|
||||
ids: [a.id, b.id, c.id, c.id, unknownId],
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
let body = await res.json();
|
||||
assert.deepEqual(body.deletedIds.sort(), [a.id, b.id, c.id].sort());
|
||||
assert.deepEqual(body.failedIds, [unknownId]);
|
||||
|
||||
// Stranger tries to bulk-delete owner's pending orders → all rejected.
|
||||
const d = await place();
|
||||
const e = await place();
|
||||
res = await call(cs, "POST", "/orders/bulk-delete", { ids: [d.id, e.id] });
|
||||
assert.equal(res.status, 200);
|
||||
body = await res.json();
|
||||
assert.deepEqual(body.deletedIds, []);
|
||||
assert.deepEqual(body.failedIds.sort(), [d.id, e.id].sort());
|
||||
|
||||
// Owner can bulk-delete only the cancelled one of two; pending stays.
|
||||
res = await call(co, "PATCH", `/orders/${d.id}/status`, { status: "cancelled" });
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(co, "POST", "/orders/bulk-delete", { ids: [d.id, e.id] });
|
||||
assert.equal(res.status, 200);
|
||||
body = await res.json();
|
||||
assert.deepEqual(body.deletedIds, [d.id]);
|
||||
assert.deepEqual(body.failedIds, [e.id]);
|
||||
|
||||
// Validation: empty ids → 400
|
||||
res = await call(cr, "POST", "/orders/bulk-delete", { ids: [] });
|
||||
assert.equal(res.status, 400);
|
||||
|
||||
// Unauthenticated → 401
|
||||
res = await fetch(`${API_BASE}/api/orders/bulk-delete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: [1] }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("owner can restore (undo) a freshly cancelled order to its prior status; expired window blocks restore", async () => {
|
||||
const owner = await createUser("undoOwn", "user");
|
||||
const recv = await createUser("undoRecv", "order_receiver");
|
||||
|
||||
@@ -190,7 +190,20 @@
|
||||
"cancelled": "تم إلغاء الطلب",
|
||||
"undo": "تراجع",
|
||||
"undone": "تمت الاستعادة",
|
||||
"restoreFailed": "تعذّر استعادة الطلب"
|
||||
"restoreFailed": "تعذّر استعادة الطلب",
|
||||
"select": "تحديد",
|
||||
"selectAll": "تحديد الكل",
|
||||
"clearSelection": "إلغاء التحديد",
|
||||
"selectedCount_one": "تم تحديد طلب واحد",
|
||||
"selectedCount_other": "تم تحديد {{count}} طلبات",
|
||||
"delete": "حذف",
|
||||
"deleteConfirmTitle": "حذف الطلبات؟",
|
||||
"deleteConfirmBody_one": "سيتم حذف طلب واحد نهائياً. لا يمكن التراجع.",
|
||||
"deleteConfirmBody_other": "سيتم حذف {{count}} طلبات نهائياً. لا يمكن التراجع.",
|
||||
"deleted_one": "تم حذف طلب واحد",
|
||||
"deleted_other": "تم حذف {{count}} طلبات",
|
||||
"deleteFailed": "تعذّر حذف الطلبات",
|
||||
"deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي"
|
||||
},
|
||||
"chat": {
|
||||
"title": "المحادثات",
|
||||
|
||||
@@ -190,7 +190,20 @@
|
||||
"cancelled": "Order cancelled",
|
||||
"undo": "Undo",
|
||||
"undone": "Restored",
|
||||
"restoreFailed": "Could not restore the order"
|
||||
"restoreFailed": "Could not restore the order",
|
||||
"select": "Select",
|
||||
"selectAll": "Select all",
|
||||
"clearSelection": "Clear selection",
|
||||
"selectedCount_one": "1 order selected",
|
||||
"selectedCount_other": "{{count}} orders selected",
|
||||
"delete": "Delete",
|
||||
"deleteConfirmTitle": "Delete orders?",
|
||||
"deleteConfirmBody_one": "1 order will be permanently deleted. This can't be undone.",
|
||||
"deleteConfirmBody_other": "{{count}} orders will be permanently deleted. This can't be undone.",
|
||||
"deleted_one": "1 order deleted",
|
||||
"deleted_other": "{{count}} orders deleted",
|
||||
"deleteFailed": "Could not delete the orders",
|
||||
"deletePartial": "Deleted {{deleted}} of {{total}}; the rest could not be deleted"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getListIncomingServiceOrdersQueryKey,
|
||||
useConfirmServiceOrderReceipt,
|
||||
useUpdateServiceOrderStatus,
|
||||
useBulkDeleteServiceOrders,
|
||||
type ServiceOrder,
|
||||
} from "@workspace/api-client-react";
|
||||
import {
|
||||
@@ -20,9 +21,21 @@ import {
|
||||
PackageCheck,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ToastAction } from "@/components/ui/toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
@@ -80,10 +93,16 @@ function IncomingOrderCard({
|
||||
order,
|
||||
meId,
|
||||
onActionDone,
|
||||
selectionMode,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
meId: number;
|
||||
onActionDone: () => void;
|
||||
selectionMode: boolean;
|
||||
selected: boolean;
|
||||
onToggleSelected: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -247,8 +266,26 @@ function IncomingOrderCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors",
|
||||
selectionMode && "cursor-pointer",
|
||||
selected && "ring-2 ring-amber-400 bg-amber-50/40",
|
||||
)}
|
||||
onClick={selectionMode ? () => onToggleSelected(order.id) : undefined}
|
||||
data-testid={`incoming-order-card-${order.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{selectionMode && (
|
||||
<div className="pt-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => onToggleSelected(order.id)}
|
||||
aria-label={t("incomingOrders.select")}
|
||||
data-testid={`order-select-${order.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OrderImage src={img} alt={serviceName} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
@@ -272,50 +309,52 @@ function IncomingOrderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{order.status === "pending" && !order.assignedTo && (
|
||||
<Button size="sm" onClick={handleConfirmReceipt} disabled={busy}>
|
||||
{claim.isPending && <Loader2 size={14} className="animate-spin me-1.5" />}
|
||||
{claim.isPending
|
||||
? t("incomingOrders.confirming")
|
||||
: t("incomingOrders.confirmReceipt")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMine && order.status === "received" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSetStatus("preparing")}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("incomingOrders.markPreparing")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMine && order.status === "preparing" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSetStatus("completed")}
|
||||
disabled={busy}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{t("incomingOrders.markCompleted")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMine &&
|
||||
(order.status === "received" || order.status === "preparing") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
|
||||
onClick={() => handleSetStatus("cancelled")}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("incomingOrders.cancel")}
|
||||
{!selectionMode && (
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{order.status === "pending" && !order.assignedTo && (
|
||||
<Button size="sm" onClick={handleConfirmReceipt} disabled={busy}>
|
||||
{claim.isPending && <Loader2 size={14} className="animate-spin me-1.5" />}
|
||||
{claim.isPending
|
||||
? t("incomingOrders.confirming")
|
||||
: t("incomingOrders.confirmReceipt")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMine && order.status === "received" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSetStatus("preparing")}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("incomingOrders.markPreparing")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMine && order.status === "preparing" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSetStatus("completed")}
|
||||
disabled={busy}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{t("incomingOrders.markCompleted")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMine &&
|
||||
(order.status === "received" || order.status === "preparing") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
|
||||
onClick={() => handleSetStatus("cancelled")}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("incomingOrders.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -324,6 +363,8 @@ export default function OrdersIncomingPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const lang = i18n.language;
|
||||
const isRtl = lang === "ar";
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||
@@ -346,6 +387,99 @@ export default function OrdersIncomingPage() {
|
||||
);
|
||||
const mine = sorted.filter((o) => o.assignedTo === meId);
|
||||
const unclaimed = sorted.filter((o) => o.status === "pending" && !o.assignedTo);
|
||||
const allVisibleIds = useMemo(
|
||||
() => [...mine, ...unclaimed].map((o) => o.id),
|
||||
[mine, unclaimed],
|
||||
);
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const bulkDelete = useBulkDeleteServiceOrders();
|
||||
|
||||
// Drop ids that are no longer visible (e.g. another receiver claimed them
|
||||
// and they fell out of our queue) so the count stays truthful.
|
||||
const validSelected = useMemo(() => {
|
||||
const visible = new Set(allVisibleIds);
|
||||
const next = new Set<number>();
|
||||
for (const id of selected) if (visible.has(id)) next.add(id);
|
||||
return next;
|
||||
}, [selected, allVisibleIds]);
|
||||
|
||||
const selectedCount = validSelected.size;
|
||||
const allSelected =
|
||||
allVisibleIds.length > 0 && selectedCount === allVisibleIds.length;
|
||||
|
||||
const exitSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(allVisibleIds));
|
||||
}
|
||||
};
|
||||
|
||||
const performDelete = () => {
|
||||
const ids = Array.from(validSelected);
|
||||
if (ids.length === 0) {
|
||||
setConfirmOpen(false);
|
||||
return;
|
||||
}
|
||||
bulkDelete.mutate(
|
||||
{ data: { ids } },
|
||||
{
|
||||
onSuccess: (resp) => {
|
||||
const deleted = resp.deletedIds.length;
|
||||
const failed = resp.failedIds.length;
|
||||
if (deleted > 0 && failed === 0) {
|
||||
toast({
|
||||
title: t("incomingOrders.deleted", { count: deleted }),
|
||||
});
|
||||
} else if (deleted > 0 && failed > 0) {
|
||||
toast({
|
||||
title: t("incomingOrders.deletePartial", {
|
||||
deleted,
|
||||
total: ids.length,
|
||||
}),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("incomingOrders.deleteFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
exitSelection();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("incomingOrders.deleteFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
@@ -357,15 +491,36 @@ export default function OrdersIncomingPage() {
|
||||
>
|
||||
<BackIcon size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox size={20} className="text-amber-500" />
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Inbox size={20} className="text-amber-500 shrink-0" />
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">
|
||||
{t("incomingOrders.title")}
|
||||
</h1>
|
||||
</div>
|
||||
{allowed && allVisibleIds.length > 0 && (
|
||||
selectionMode ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={exitSelection}
|
||||
data-testid="cancel-selection"
|
||||
>
|
||||
{t("incomingOrders.clearSelection")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectionMode(true)}
|
||||
data-testid="enter-selection"
|
||||
>
|
||||
{t("incomingOrders.select")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 pb-8 max-w-2xl mx-auto w-full">
|
||||
<div className="flex-1 p-4 pb-24 max-w-2xl mx-auto w-full">
|
||||
{!allowed ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<Inbox size={36} className="text-slate-300" />
|
||||
@@ -398,6 +553,18 @@ export default function OrdersIncomingPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{selectionMode && (
|
||||
<label className="flex items-center gap-2 text-sm text-foreground/80 px-1">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label={t("incomingOrders.selectAll")}
|
||||
data-testid="select-all"
|
||||
/>
|
||||
<span>{t("incomingOrders.selectAll")}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{mine.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
|
||||
@@ -410,6 +577,9 @@ export default function OrdersIncomingPage() {
|
||||
order={o}
|
||||
meId={meId}
|
||||
onActionDone={refetch}
|
||||
selectionMode={selectionMode}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -427,6 +597,9 @@ export default function OrdersIncomingPage() {
|
||||
order={o}
|
||||
meId={meId}
|
||||
onActionDone={refetch}
|
||||
selectionMode={selectionMode}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -435,6 +608,63 @@ export default function OrdersIncomingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && selectedCount > 0 && (
|
||||
<div
|
||||
className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur px-4 py-3 flex items-center gap-3 max-w-2xl mx-auto"
|
||||
data-testid="bulk-action-bar"
|
||||
>
|
||||
<div className="flex-1 text-sm text-foreground">
|
||||
{t("incomingOrders.selectedCount", { count: selectedCount })}
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={bulkDelete.isPending}
|
||||
data-testid="bulk-delete"
|
||||
>
|
||||
{bulkDelete.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin me-1.5" />
|
||||
) : (
|
||||
<Trash2 size={14} className="me-1.5" />
|
||||
)}
|
||||
{t("incomingOrders.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("incomingOrders.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("incomingOrders.deleteConfirmBody", { count: selectedCount })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkDelete.isPending}>
|
||||
{t("common.cancel", { defaultValue: t("incomingOrders.clearSelection") })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
performDelete();
|
||||
}}
|
||||
disabled={bulkDelete.isPending}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||
data-testid="confirm-delete"
|
||||
>
|
||||
{bulkDelete.isPending && (
|
||||
<Loader2 size={14} className="animate-spin me-1.5" />
|
||||
)}
|
||||
{t("incomingOrders.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -622,6 +622,21 @@ export interface UpdateServiceOrderStatusBody {
|
||||
status: UpdateServiceOrderStatusBodyStatus;
|
||||
}
|
||||
|
||||
export interface BulkDeleteServiceOrdersBody {
|
||||
/**
|
||||
* @minItems 1
|
||||
* @maxItems 200
|
||||
*/
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export interface BulkDeleteServiceOrdersResponse {
|
||||
/** Ids that were successfully deleted. */
|
||||
deletedIds: number[];
|
||||
/** Ids that were skipped (not found, or caller is not authorized). */
|
||||
failedIds: number[];
|
||||
}
|
||||
|
||||
export interface ParticipantInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
AppSettings,
|
||||
AuditLogList,
|
||||
AuthUser,
|
||||
BulkDeleteServiceOrdersBody,
|
||||
BulkDeleteServiceOrdersResponse,
|
||||
ConversationWithDetails,
|
||||
CreateAppBody,
|
||||
CreateConversationBody,
|
||||
@@ -4962,9 +4964,105 @@ export const useDeleteUser = <
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Deletes several orders in one round-trip. Authorization is still
|
||||
enforced per id using the same rules as DELETE /orders/{id}, so the
|
||||
client cannot bypass the permission check by batching. Returns a
|
||||
summary of which ids were deleted and which were rejected.
|
||||
|
||||
* @summary Delete multiple service orders in one request
|
||||
*/
|
||||
export const getBulkDeleteServiceOrdersUrl = () => {
|
||||
return `/api/orders/bulk-delete`;
|
||||
};
|
||||
|
||||
export const bulkDeleteServiceOrders = async (
|
||||
bulkDeleteServiceOrdersBody: BulkDeleteServiceOrdersBody,
|
||||
options?: RequestInit,
|
||||
): Promise<BulkDeleteServiceOrdersResponse> => {
|
||||
return customFetch<BulkDeleteServiceOrdersResponse>(
|
||||
getBulkDeleteServiceOrdersUrl(),
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(bulkDeleteServiceOrdersBody),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getBulkDeleteServiceOrdersMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
|
||||
TError,
|
||||
{ data: BodyType<BulkDeleteServiceOrdersBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
|
||||
TError,
|
||||
{ data: BodyType<BulkDeleteServiceOrdersBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["bulkDeleteServiceOrders"];
|
||||
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 bulkDeleteServiceOrders>>,
|
||||
{ data: BodyType<BulkDeleteServiceOrdersBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return bulkDeleteServiceOrders(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type BulkDeleteServiceOrdersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>
|
||||
>;
|
||||
export type BulkDeleteServiceOrdersMutationBody =
|
||||
BodyType<BulkDeleteServiceOrdersBody>;
|
||||
export type BulkDeleteServiceOrdersMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Delete multiple service orders in one request
|
||||
*/
|
||||
export const useBulkDeleteServiceOrders = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
|
||||
TError,
|
||||
{ data: BodyType<BulkDeleteServiceOrdersBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof bulkDeleteServiceOrders>>,
|
||||
TError,
|
||||
{ data: BodyType<BulkDeleteServiceOrdersBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getBulkDeleteServiceOrdersMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Permanently deletes the order. Allowed when the caller is an admin,
|
||||
when the caller owns the order AND it is in a terminal status
|
||||
(completed/cancelled), OR when the caller holds the `orders.receive`
|
||||
permission (i.e. a receiver clearing items from their incoming queue).
|
||||
|
||||
* @summary Delete a service order
|
||||
*/
|
||||
|
||||
@@ -1350,15 +1350,46 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDeletionConflict"
|
||||
|
||||
/orders/bulk-delete:
|
||||
post:
|
||||
operationId: bulkDeleteServiceOrders
|
||||
tags: [orders]
|
||||
summary: Delete multiple service orders in one request
|
||||
description: |
|
||||
Deletes several orders in one round-trip. Authorization is still
|
||||
enforced per id using the same rules as DELETE /orders/{id}, so the
|
||||
client cannot bypass the permission check by batching. Returns a
|
||||
summary of which ids were deleted and which were rejected.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BulkDeleteServiceOrdersBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Bulk delete summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BulkDeleteServiceOrdersResponse"
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/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).
|
||||
Permanently deletes the order. Allowed when the caller is an admin,
|
||||
when the caller owns the order AND it is in a terminal status
|
||||
(completed/cancelled), OR when the caller holds the `orders.receive`
|
||||
permission (i.e. a receiver clearing items from their incoming queue).
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -3946,6 +3977,35 @@ components:
|
||||
required:
|
||||
- status
|
||||
|
||||
BulkDeleteServiceOrdersBody:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
required:
|
||||
- ids
|
||||
|
||||
BulkDeleteServiceOrdersResponse:
|
||||
type: object
|
||||
properties:
|
||||
deletedIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
description: Ids that were successfully deleted.
|
||||
failedIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
description: Ids that were skipped (not found, or caller is not authorized).
|
||||
required:
|
||||
- deletedIds
|
||||
- failedIds
|
||||
|
||||
ConversationWithDetails:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2000,9 +2000,35 @@ export const DeleteUserQueryParams = zod.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Deletes several orders in one round-trip. Authorization is still
|
||||
enforced per id using the same rules as DELETE /orders/{id}, so the
|
||||
client cannot bypass the permission check by batching. Returns a
|
||||
summary of which ids were deleted and which were rejected.
|
||||
|
||||
* @summary Delete multiple service orders in one request
|
||||
*/
|
||||
export const bulkDeleteServiceOrdersBodyIdsMax = 200;
|
||||
|
||||
export const BulkDeleteServiceOrdersBody = zod.object({
|
||||
ids: zod.array(zod.number()).min(1).max(bulkDeleteServiceOrdersBodyIdsMax),
|
||||
});
|
||||
|
||||
export const BulkDeleteServiceOrdersResponse = zod.object({
|
||||
deletedIds: zod
|
||||
.array(zod.number())
|
||||
.describe("Ids that were successfully deleted."),
|
||||
failedIds: zod
|
||||
.array(zod.number())
|
||||
.describe(
|
||||
"Ids that were skipped (not found, or caller is not authorized).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Permanently deletes the order. Allowed when the caller is an admin,
|
||||
when the caller owns the order AND it is in a terminal status
|
||||
(completed/cancelled), OR when the caller holds the `orders.receive`
|
||||
permission (i.e. a receiver clearing items from their incoming queue).
|
||||
|
||||
* @summary Delete a service order
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user