Task #397: Per-card and bulk select+delete on Incoming Orders
Backend (artifacts/api-server):
- Added hasReceivePermission() and refactored DELETE /orders/:id into a
shared authorizeAndDeleteOrder() helper 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: receivers may only delete orders
currently in the incoming queue (pending/received/preparing). Terminal
orders remain the owner's cleanup responsibility. Code, comments and
OpenAPI description 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 — both
useDeleteServiceOrder and useBulkDeleteServiceOrders are used by the UI.
UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Per-card checkbox visible at all times (RTL-safe leading edge).
- Per-card trash icon for single delete.
- Section-scoped Select-all (مين / unclaimed) with tri-state
all/some(indeterminate)/none and shadcn Checkbox.
- Sticky bottom bulk action bar shows selected count + Clear selection +
destructive Delete.
- Single AlertDialog used by both per-card and bulk paths, with
pluralized confirmation copy. Single delete calls DELETE /orders/:id;
multi delete calls POST /orders/bulk-delete; partial-failure surfaces
via toast.
i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization in both languages.
Tests:
- artifacts/api-server/tests/service-orders.test.mjs: receivers can
delete pending/received/preparing; receivers cannot delete terminal
(completed/cancelled); bulk-delete with mixed authorized / unknown /
dedup / unauthenticated cases.
- artifacts/tx-os/tests/order-incoming-delete.spec.mjs (new Playwright):
bulk-delete two unclaimed orders shrinks the list by 2; per-card
trash on a claimed order deletes one. Both pass.
Pre-existing executive-meetings PDF/font test failures are unrelated.
Follow-ups proposed: #398 (Undo for incoming-order deletes), #399
(safer notification handling in bulk-delete).
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
useConfirmServiceOrderReceipt,
|
||||
useUpdateServiceOrderStatus,
|
||||
useBulkDeleteServiceOrders,
|
||||
useDeleteServiceOrder,
|
||||
type ServiceOrder,
|
||||
} from "@workspace/api-client-react";
|
||||
import {
|
||||
@@ -89,20 +90,27 @@ function StatusIcon({ status }: { status: ServiceOrder["status"] }) {
|
||||
return <XCircle size={14} className={cn(cls, "text-rose-600")} />;
|
||||
}
|
||||
|
||||
type TriState = "all" | "some" | "none";
|
||||
function triState(selectedCount: number, total: number): TriState {
|
||||
if (total === 0 || selectedCount === 0) return "none";
|
||||
if (selectedCount >= total) return "all";
|
||||
return "some";
|
||||
}
|
||||
|
||||
function IncomingOrderCard({
|
||||
order,
|
||||
meId,
|
||||
onActionDone,
|
||||
selectionMode,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
meId: number;
|
||||
onActionDone: () => void;
|
||||
selectionMode: boolean;
|
||||
selected: boolean;
|
||||
onToggleSelected: (id: number) => void;
|
||||
onRequestDelete: (id: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -269,23 +277,19 @@ function IncomingOrderCard({
|
||||
<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>
|
||||
)}
|
||||
<div className="pt-1 shrink-0">
|
||||
<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">
|
||||
@@ -307,54 +311,61 @@ function IncomingOrderCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestDelete(order.id)}
|
||||
className="p-1.5 rounded-md text-rose-700 hover:bg-rose-50 hover:text-rose-800 transition-colors shrink-0"
|
||||
aria-label={t("incomingOrders.delete")}
|
||||
data-testid={`order-delete-${order.id}`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!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 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" && (
|
||||
{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"
|
||||
onClick={() => handleSetStatus("preparing")}
|
||||
variant="ghost"
|
||||
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
|
||||
onClick={() => handleSetStatus("cancelled")}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("incomingOrders.markPreparing")}
|
||||
{t("incomingOrders.cancel")}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -387,16 +398,20 @@ export default function OrdersIncomingPage() {
|
||||
);
|
||||
const mine = sorted.filter((o) => o.assignedTo === meId);
|
||||
const unclaimed = sorted.filter((o) => o.status === "pending" && !o.assignedTo);
|
||||
const mineIds = useMemo(() => mine.map((o) => o.id), [mine]);
|
||||
const unclaimedIds = useMemo(() => unclaimed.map((o) => o.id), [unclaimed]);
|
||||
const allVisibleIds = useMemo(
|
||||
() => [...mine, ...unclaimed].map((o) => o.id),
|
||||
[mine, unclaimed],
|
||||
() => [...mineIds, ...unclaimedIds],
|
||||
[mineIds, unclaimedIds],
|
||||
);
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
// confirmIds === null → no dialog; otherwise the ids that confirming will delete.
|
||||
const [confirmIds, setConfirmIds] = useState<number[] | null>(null);
|
||||
|
||||
const bulkDelete = useBulkDeleteServiceOrders();
|
||||
const deleteOne = useDeleteServiceOrder();
|
||||
const isDeleting = bulkDelete.isPending || deleteOne.isPending;
|
||||
|
||||
// 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.
|
||||
@@ -408,13 +423,14 @@ export default function OrdersIncomingPage() {
|
||||
}, [selected, allVisibleIds]);
|
||||
|
||||
const selectedCount = validSelected.size;
|
||||
const allSelected =
|
||||
allVisibleIds.length > 0 && selectedCount === allVisibleIds.length;
|
||||
const mineSelectedCount = mineIds.filter((id) => validSelected.has(id)).length;
|
||||
const unclaimedSelectedCount = unclaimedIds.filter((id) =>
|
||||
validSelected.has(id),
|
||||
).length;
|
||||
const mineState = triState(mineSelectedCount, mineIds.length);
|
||||
const unclaimedState = triState(unclaimedSelectedCount, unclaimedIds.length);
|
||||
|
||||
const exitSelection = () => {
|
||||
setSelectionMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
@@ -425,62 +441,93 @@ export default function OrdersIncomingPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set());
|
||||
const setSectionSelected = (sectionIds: number[], on: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) for (const id of sectionIds) next.add(id);
|
||||
else for (const id of sectionIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const requestDelete = (id: number) => setConfirmIds([id]);
|
||||
const requestBulkDelete = () => {
|
||||
const ids = Array.from(validSelected);
|
||||
if (ids.length === 0) return;
|
||||
setConfirmIds(ids);
|
||||
};
|
||||
|
||||
const showDeletedToast = (deleted: number, total: number) => {
|
||||
if (deleted > 0 && deleted === total) {
|
||||
toast({ title: t("incomingOrders.deleted", { count: deleted }) });
|
||||
} else if (deleted > 0) {
|
||||
toast({
|
||||
title: t("incomingOrders.deletePartial", { deleted, total }),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
setSelected(new Set(allVisibleIds));
|
||||
toast({
|
||||
title: t("incomingOrders.deleteFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finishDelete = (deletedIds: number[]) => {
|
||||
if (deletedIds.length > 0) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of deletedIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setConfirmIds(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
refetch();
|
||||
};
|
||||
|
||||
const performDelete = () => {
|
||||
const ids = Array.from(validSelected);
|
||||
const ids = confirmIds ?? [];
|
||||
if (ids.length === 0) {
|
||||
setConfirmOpen(false);
|
||||
setConfirmIds(null);
|
||||
return;
|
||||
}
|
||||
if (ids.length === 1) {
|
||||
const [only] = ids;
|
||||
deleteOne.mutate(
|
||||
{ id: only },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showDeletedToast(1, 1);
|
||||
finishDelete([only]);
|
||||
},
|
||||
onError: () => {
|
||||
showDeletedToast(0, 1);
|
||||
setConfirmIds(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
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();
|
||||
showDeletedToast(resp.deletedIds.length, ids.length);
|
||||
finishDelete(resp.deletedIds);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("incomingOrders.deleteFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
showDeletedToast(0, ids.length);
|
||||
setConfirmIds(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const confirmCount = confirmIds?.length ?? 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -497,27 +544,6 @@ export default function OrdersIncomingPage() {
|
||||
{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-24 max-w-2xl mx-auto w-full">
|
||||
@@ -553,23 +579,30 @@ 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">
|
||||
{t("incomingOrders.subtitleMine")}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("incomingOrders.subtitleMine")}
|
||||
</h2>
|
||||
<label className="flex items-center gap-2 text-xs text-foreground/80 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={
|
||||
mineState === "all"
|
||||
? true
|
||||
: mineState === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
setSectionSelected(mineIds, v === true)
|
||||
}
|
||||
aria-label={t("incomingOrders.selectAll")}
|
||||
data-testid="select-all-mine"
|
||||
/>
|
||||
<span>{t("incomingOrders.selectAll")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{mine.map((o) => (
|
||||
<IncomingOrderCard
|
||||
@@ -577,9 +610,9 @@ export default function OrdersIncomingPage() {
|
||||
order={o}
|
||||
meId={meId}
|
||||
onActionDone={refetch}
|
||||
selectionMode={selectionMode}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
onRequestDelete={requestDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -587,9 +620,28 @@ export default function OrdersIncomingPage() {
|
||||
)}
|
||||
{unclaimed.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
|
||||
{t("incomingOrders.subtitleUnclaimed")}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t("incomingOrders.subtitleUnclaimed")}
|
||||
</h2>
|
||||
<label className="flex items-center gap-2 text-xs text-foreground/80 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={
|
||||
unclaimedState === "all"
|
||||
? true
|
||||
: unclaimedState === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
setSectionSelected(unclaimedIds, v === true)
|
||||
}
|
||||
aria-label={t("incomingOrders.selectAll")}
|
||||
data-testid="select-all-unclaimed"
|
||||
/>
|
||||
<span>{t("incomingOrders.selectAll")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{unclaimed.map((o) => (
|
||||
<IncomingOrderCard
|
||||
@@ -597,9 +649,9 @@ export default function OrdersIncomingPage() {
|
||||
order={o}
|
||||
meId={meId}
|
||||
onActionDone={refetch}
|
||||
selectionMode={selectionMode}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
onRequestDelete={requestDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -609,7 +661,7 @@ export default function OrdersIncomingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && selectedCount > 0 && (
|
||||
{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"
|
||||
@@ -617,14 +669,23 @@ export default function OrdersIncomingPage() {
|
||||
<div className="flex-1 text-sm text-foreground">
|
||||
{t("incomingOrders.selectedCount", { count: selectedCount })}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
disabled={isDeleting}
|
||||
data-testid="clear-selection"
|
||||
>
|
||||
{t("incomingOrders.clearSelection")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={bulkDelete.isPending}
|
||||
onClick={requestBulkDelete}
|
||||
disabled={isDeleting}
|
||||
data-testid="bulk-delete"
|
||||
>
|
||||
{bulkDelete.isPending ? (
|
||||
{isDeleting ? (
|
||||
<Loader2 size={14} className="animate-spin me-1.5" />
|
||||
) : (
|
||||
<Trash2 size={14} className="me-1.5" />
|
||||
@@ -634,30 +695,35 @@ export default function OrdersIncomingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialog
|
||||
open={confirmIds !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isDeleting) setConfirmIds(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("incomingOrders.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("incomingOrders.deleteConfirmBody", { count: selectedCount })}
|
||||
{t("incomingOrders.deleteConfirmBody", { count: confirmCount })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkDelete.isPending}>
|
||||
{t("common.cancel", { defaultValue: t("incomingOrders.clearSelection") })}
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t("incomingOrders.clearSelection")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
performDelete();
|
||||
}}
|
||||
disabled={bulkDelete.isPending}
|
||||
disabled={isDeleting}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||
data-testid="confirm-delete"
|
||||
>
|
||||
{bulkDelete.isPending && (
|
||||
{isDeleting && (
|
||||
<Loader2 size={14} className="animate-spin me-1.5" />
|
||||
)}
|
||||
{t("incomingOrders.delete")}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// Playwright e2e for the per-card and bulk delete affordances on
|
||||
// /orders/incoming added in task #397.
|
||||
//
|
||||
// Covers:
|
||||
// - Bulk delete: receiver selects 2 unclaimed orders via per-card
|
||||
// checkboxes, opens the confirm dialog from the sticky bulk action
|
||||
// bar, confirms, and the list shrinks by 2 (the rest stay).
|
||||
// - Per-card delete: receiver claims a third order, then deletes it
|
||||
// from the "my claimed" section using the per-card trash icon and
|
||||
// the same confirm dialog. The list shrinks by 1.
|
||||
//
|
||||
// Mirrors the existing order-receiver-flow.spec.mjs harness (same
|
||||
// password hash, same DB cleanup pattern).
|
||||
//
|
||||
// Run with:
|
||||
// DATABASE_URL=... pnpm --filter @workspace/tx-os test:e2e
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error(
|
||||
"DATABASE_URL must be set to run the incoming-orders delete UI test",
|
||||
);
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdOrderIds = [];
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, roleName, displayNameEn) {
|
||||
const username = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[
|
||||
username,
|
||||
`${username}@example.com`,
|
||||
TEST_PASSWORD_HASH,
|
||||
displayNameEn ?? username,
|
||||
],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2`,
|
||||
[id, roleName],
|
||||
);
|
||||
return { id, username, displayName: displayNameEn ?? username };
|
||||
}
|
||||
|
||||
async function getAvailableService() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name_en AS "nameEn", name_ar AS "nameAr"
|
||||
FROM services WHERE is_available = true
|
||||
ORDER BY id ASC LIMIT 1`,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No available service found in DB");
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function placeOrderDirect(userId, serviceId, notes) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO service_orders (user_id, service_id, status, notes)
|
||||
VALUES ($1, $2, 'pending', $3) RETURNING id`,
|
||||
[userId, serviceId, notes],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdOrderIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdOrderIds.length > 0) {
|
||||
await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
|
||||
createdOrderIds,
|
||||
]);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM service_orders
|
||||
WHERE user_id = ANY($1::int[]) OR assigned_to = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function setLanguage(page, lang) {
|
||||
await page.addInitScript((l) => {
|
||||
try {
|
||||
window.localStorage.setItem("tx-lang", l);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, lang);
|
||||
}
|
||||
|
||||
async function loginViaUi(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function countCards(page) {
|
||||
return page.locator('[data-testid^="incoming-order-card-"]').count();
|
||||
}
|
||||
|
||||
test.describe("Incoming orders — delete affordances", () => {
|
||||
test("bulk delete two unclaimed orders shrinks the list by 2", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const requester = await createUser(
|
||||
"ord_del_req",
|
||||
"user",
|
||||
`DelReq ${uniqueSuffix()}`,
|
||||
);
|
||||
const receiver = await createUser(
|
||||
"ord_del_recv",
|
||||
"order_receiver",
|
||||
`DelRecv ${uniqueSuffix()}`,
|
||||
);
|
||||
const service = await getAvailableService();
|
||||
|
||||
// Three pending unclaimed orders so we can assert the post-delete
|
||||
// count is exactly one.
|
||||
const o1 = await placeOrderDirect(requester.id, service.id, "delete-me-1");
|
||||
const o2 = await placeOrderDirect(requester.id, service.id, "delete-me-2");
|
||||
const o3 = await placeOrderDirect(requester.id, service.id, "keep-me");
|
||||
|
||||
const ctx = await browser.newContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
await setLanguage(page, "en");
|
||||
await loginViaUi(page, receiver.username, TEST_PASSWORD);
|
||||
|
||||
await page.goto("/orders/incoming");
|
||||
await expect(
|
||||
page.locator(`[data-testid="incoming-order-card-${o1}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`[data-testid="incoming-order-card-${o2}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`[data-testid="incoming-order-card-${o3}"]`),
|
||||
).toBeVisible();
|
||||
|
||||
const initial = await countCards(page);
|
||||
expect(initial).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Select two via per-card checkboxes (always visible).
|
||||
await page.locator(`[data-testid="order-select-${o1}"]`).click();
|
||||
await page.locator(`[data-testid="order-select-${o2}"]`).click();
|
||||
|
||||
const bar = page.locator('[data-testid="bulk-action-bar"]');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(bar).toContainText("2 orders selected");
|
||||
|
||||
const bulkPromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const path = new URL(resp.url()).pathname;
|
||||
return (
|
||||
path === "/api/orders/bulk-delete" &&
|
||||
resp.request().method() === "POST"
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.locator('[data-testid="bulk-delete"]').click();
|
||||
await page.locator('[data-testid="confirm-delete"]').click();
|
||||
const bulkResp = await bulkPromise;
|
||||
expect(bulkResp.status()).toBe(200);
|
||||
const body = await bulkResp.json();
|
||||
expect(body.deletedIds.sort()).toEqual([o1, o2].sort());
|
||||
expect(body.failedIds).toEqual([]);
|
||||
|
||||
await expect(
|
||||
page.locator(`[data-testid="incoming-order-card-${o1}"]`),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(`[data-testid="incoming-order-card-${o2}"]`),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(`[data-testid="incoming-order-card-${o3}"]`),
|
||||
).toBeVisible();
|
||||
|
||||
const after = await countCards(page);
|
||||
expect(after).toBe(initial - 2);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("per-card trash deletes a single claimed order", async ({ browser }) => {
|
||||
const requester = await createUser(
|
||||
"ord_del_req2",
|
||||
"user",
|
||||
`DelReq2 ${uniqueSuffix()}`,
|
||||
);
|
||||
const receiver = await createUser(
|
||||
"ord_del_recv2",
|
||||
"order_receiver",
|
||||
`DelRecv2 ${uniqueSuffix()}`,
|
||||
);
|
||||
const service = await getAvailableService();
|
||||
|
||||
const o1 = await placeOrderDirect(requester.id, service.id, "claim-me");
|
||||
|
||||
const ctx = await browser.newContext();
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
await setLanguage(page, "en");
|
||||
await loginViaUi(page, receiver.username, TEST_PASSWORD);
|
||||
|
||||
await page.goto("/orders/incoming");
|
||||
const card = page.locator(
|
||||
`[data-testid="incoming-order-card-${o1}"]`,
|
||||
);
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Claim the order so it moves to "My active orders".
|
||||
const claimPromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const path = new URL(resp.url()).pathname;
|
||||
return (
|
||||
path === `/api/orders/${o1}/confirm-receipt` &&
|
||||
resp.request().method() === "PATCH"
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await card.getByRole("button", { name: "Confirm Receipt", exact: true }).click();
|
||||
const claimResp = await claimPromise;
|
||||
expect(claimResp.status()).toBe(200);
|
||||
|
||||
// Now delete via per-card trash.
|
||||
const before = await countCards(page);
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) => {
|
||||
const path = new URL(resp.url()).pathname;
|
||||
return (
|
||||
path === `/api/orders/${o1}` &&
|
||||
resp.request().method() === "DELETE"
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.locator(`[data-testid="order-delete-${o1}"]`).click();
|
||||
await page.locator('[data-testid="confirm-delete"]').click();
|
||||
const delResp = await deletePromise;
|
||||
expect(delResp.status()).toBe(204);
|
||||
|
||||
await expect(card).toHaveCount(0);
|
||||
const after = await countCards(page);
|
||||
expect(after).toBe(before - 1);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user