fix: tell receivers when an order is no longer available + fix disabled apps disappearing from admin
## Task #80 — Tell receivers when an order is no longer available to claim ### Problem When a receiver tried to act on an order that had already been cancelled, completed, or claimed by someone else, the UI showed a generic "Could not complete the action" toast. The stale card also stayed in the list, requiring a manual refresh. ### Changes **artifacts/api-server/src/routes/service-orders.ts** - PATCH /orders/:id/confirm-receipt: after a failed atomic claim, query the current order status; return 409 `order_unavailable` if it's cancelled/completed (vs the existing 409 `already_claimed` for when it was grabbed by someone else first) - PATCH /orders/:id/status (preparing/completed branch): terminal-state check runs BEFORE permission gating — returns 409 `order_unavailable` when order is already cancelled/completed instead of ever hitting 403 Forbidden - PATCH /orders/:id/status (cancel branch): same — terminal-state check moved to the top of the branch so non-admin receivers trying to cancel an already-terminal order get 409 `order_unavailable` (not 403 Forbidden which would never let the UI remove the stale card) **artifacts/tx-os/src/pages/orders-incoming.tsx** - handleConfirmReceipt onError: distinguish 409 `order_unavailable` from `already_claimed`, show specific toast, remove stale card via invalidate() - handleSetStatus onError: same pattern — specific toast + invalidate() on `order_unavailable` - handleCancel onError: same pattern **artifacts/tx-os/src/locales/en.json + ar.json** - Added `incomingOrders.orderUnavailable` ("This order is no longer available" / "هذا الطلب لم يعد متاحاً") ## Bonus fix — Disabled app disappears from admin panel (user-reported) ### Problem When an admin disabled an app via the toggle, the app disappeared from the admin panel too (isActive filter applied to everyone), making it impossible to re-enable without direct DB access. ### Changes **artifacts/api-server/src/routes/apps.ts** - getVisibleAppsForUser: admins now receive ALL apps including inactive ones via conditional $dynamic() Drizzle query; non-admins still only see active apps **artifacts/tx-os/src/pages/home.tsx** - Filter orderedApps to only active apps before rendering so inactive apps don't appear on the home screen even for admins **artifacts/tx-os/src/pages/admin.tsx** - Inactive app cards show reduced opacity + a "Disabled/معطّل" badge **artifacts/tx-os/src/locales/en.json + ar.json** - Added `admin.appDisabled` ("Disabled" / "معطّل")
This commit is contained in:
@@ -37,7 +37,9 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
|
||||
const isAdmin = adminRoleRows.length > 0;
|
||||
|
||||
// Pull the user's preferred order, then order by COALESCE(user_order, app.sort_order, name).
|
||||
const orderedRows = await db
|
||||
// Admins receive ALL apps (including inactive ones) so they can re-enable them.
|
||||
// Non-admins only receive active apps.
|
||||
const baseQuery = db
|
||||
.select({
|
||||
app: appsTable,
|
||||
userSort: userAppOrdersTable.sortOrder,
|
||||
@@ -47,11 +49,15 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
|
||||
userAppOrdersTable,
|
||||
sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`,
|
||||
)
|
||||
.where(eq(appsTable.isActive, true))
|
||||
.orderBy(
|
||||
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
|
||||
asc(appsTable.nameEn),
|
||||
);
|
||||
.$dynamic();
|
||||
|
||||
const orderedRows = await (isAdmin
|
||||
? baseQuery
|
||||
: baseQuery.where(eq(appsTable.isActive, true))
|
||||
).orderBy(
|
||||
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
|
||||
asc(appsTable.nameEn),
|
||||
);
|
||||
|
||||
const apps = orderedRows.map((r) => r.app);
|
||||
|
||||
|
||||
@@ -248,7 +248,18 @@ router.patch(
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
res.status(409).json({ error: "already_claimed" });
|
||||
// Distinguish: was it claimed by someone else, or is it no longer claimable
|
||||
// (cancelled / completed / already in a terminal state)?
|
||||
const current = await db
|
||||
.select({ status: serviceOrdersTable.status })
|
||||
.from(serviceOrdersTable)
|
||||
.where(eq(serviceOrdersTable.id, orderId));
|
||||
const currentStatus = current[0]?.status;
|
||||
if (!currentStatus || currentStatus === "cancelled" || currentStatus === "completed") {
|
||||
res.status(409).json({ error: "order_unavailable" });
|
||||
} else {
|
||||
res.status(409).json({ error: "already_claimed" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -345,6 +356,13 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
} else if (next === "preparing" || next === "completed") {
|
||||
// If the order is already terminal (cancelled or completed by someone
|
||||
// else), surface a specific error so the receiver UI can show the right
|
||||
// message and remove the stale card.
|
||||
if (existing.status === "cancelled" || existing.status === "completed") {
|
||||
res.status(409).json({ error: "order_unavailable" });
|
||||
return;
|
||||
}
|
||||
// Only the assigned receiver or admin
|
||||
const isAssignee = existing.assignedTo === userId;
|
||||
if (!isAssignee && !admin) {
|
||||
@@ -365,6 +383,12 @@ router.patch(
|
||||
res.status(400).json({ error: "invalid_transition" });
|
||||
return;
|
||||
} else if (next === "cancelled") {
|
||||
// Check terminal state first so the receiver sees the right message
|
||||
// regardless of permission gating order.
|
||||
if (existing.status === "cancelled" || existing.status === "completed") {
|
||||
res.status(409).json({ error: "order_unavailable" });
|
||||
return;
|
||||
}
|
||||
const isOwner = existing.userId === userId;
|
||||
const isAssignee = existing.assignedTo === userId;
|
||||
const cancellableByOwner =
|
||||
@@ -379,11 +403,6 @@ router.patch(
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
// Admin can cancel any status. Already-cancelled is a no-op invalid transition.
|
||||
if (existing.status === "cancelled") {
|
||||
res.status(400).json({ error: "invalid_transition" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.status(400).json({ error: "invalid_transition" });
|
||||
return;
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"markCompleted": "إنجاز",
|
||||
"cancel": "إلغاء",
|
||||
"alreadyClaimed": "استلمه شخص آخر قبلك",
|
||||
"orderUnavailable": "هذا الطلب لم يعد متاحاً",
|
||||
"actionFailed": "تعذّر تنفيذ الإجراء",
|
||||
"claimed": "تم استلام الطلب",
|
||||
"marked": "تم تحديث الحالة",
|
||||
@@ -296,6 +297,7 @@
|
||||
"manageApps": "إدارة التطبيقات",
|
||||
"manageServices": "إدارة الخدمات",
|
||||
"manageUsers": "إدارة المستخدمين",
|
||||
"appDisabled": "معطّل",
|
||||
"addApp": "إضافة تطبيق",
|
||||
"addService": "إضافة خدمة",
|
||||
"addUser": "إضافة مستخدم",
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"markCompleted": "Mark Completed",
|
||||
"cancel": "Cancel",
|
||||
"alreadyClaimed": "Already claimed by someone else",
|
||||
"orderUnavailable": "This order is no longer available",
|
||||
"actionFailed": "Could not complete the action",
|
||||
"claimed": "Order received",
|
||||
"marked": "Status updated",
|
||||
@@ -293,6 +294,7 @@
|
||||
"manageApps": "Manage Apps",
|
||||
"manageServices": "Manage Services",
|
||||
"manageUsers": "Manage Users",
|
||||
"appDisabled": "Disabled",
|
||||
"addApp": "Add App",
|
||||
"addService": "Add Service",
|
||||
"addUser": "Add User",
|
||||
|
||||
@@ -637,15 +637,22 @@ export default function AdminPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{apps?.map((app) => (
|
||||
<div key={app.id} className="glass-panel rounded-2xl p-4 flex items-center justify-between gap-3">
|
||||
<div key={app.id} className={cn("glass-panel rounded-2xl p-4 flex items-center justify-between gap-3", !app.isActive && "opacity-60")}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${app.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate">
|
||||
{lang === "ar" ? app.nameAr : app.nameEn}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm text-foreground truncate">
|
||||
{lang === "ar" ? app.nameAr : app.nameEn}
|
||||
</div>
|
||||
{!app.isActive && (
|
||||
<span className="text-[10px] font-medium bg-slate-200 text-slate-500 rounded px-1.5 py-0.5 shrink-0">
|
||||
{t("admin.appDisabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{app.route}</div>
|
||||
</div>
|
||||
|
||||
@@ -299,14 +299,15 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!apps) return;
|
||||
const activeApps = apps.filter((a) => a.isActive);
|
||||
setOrderedApps((prev) => {
|
||||
if (!prev) return apps;
|
||||
const byId = new Map(apps.map((a) => [a.id, a]));
|
||||
if (!prev) return activeApps;
|
||||
const byId = new Map(activeApps.map((a) => [a.id, a]));
|
||||
const kept = prev
|
||||
.map((a) => byId.get(a.id))
|
||||
.filter((a): a is App => a !== undefined);
|
||||
const keptIds = new Set(kept.map((a) => a.id));
|
||||
const added = apps.filter((a) => !keptIds.has(a.id));
|
||||
const added = activeApps.filter((a) => !keptIds.has(a.id));
|
||||
return [...kept, ...added];
|
||||
});
|
||||
}, [apps]);
|
||||
|
||||
@@ -119,9 +119,15 @@ function IncomingOrderCard({
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const status = (err as { status?: number; response?: { status?: number } } | null)
|
||||
?.status ?? (err as { response?: { status?: number } } | null)?.response?.status;
|
||||
if (status === 409) {
|
||||
const apiErr = err as { status?: number; data?: { error?: string } } | null;
|
||||
const status = apiErr?.status;
|
||||
const code = apiErr?.data?.error;
|
||||
if (status === 409 && code === "order_unavailable") {
|
||||
toast({
|
||||
title: t("incomingOrders.orderUnavailable"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else if (status === 409) {
|
||||
toast({
|
||||
title: t("incomingOrders.alreadyClaimed"),
|
||||
variant: "destructive",
|
||||
@@ -150,11 +156,20 @@ function IncomingOrderCard({
|
||||
toast({ title: t("incomingOrders.marked") });
|
||||
invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("incomingOrders.actionFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
onError: (err: unknown) => {
|
||||
const apiErr = err as { status?: number; data?: { error?: string } } | null;
|
||||
if (apiErr?.status === 409 && apiErr?.data?.error === "order_unavailable") {
|
||||
toast({
|
||||
title: t("incomingOrders.orderUnavailable"),
|
||||
variant: "destructive",
|
||||
});
|
||||
invalidate();
|
||||
} else {
|
||||
toast({
|
||||
title: t("incomingOrders.actionFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -203,11 +218,20 @@ function IncomingOrderCard({
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("incomingOrders.actionFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
onError: (err: unknown) => {
|
||||
const apiErr = err as { status?: number; data?: { error?: string } } | null;
|
||||
if (apiErr?.status === 409 && apiErr?.data?.error === "order_unavailable") {
|
||||
toast({
|
||||
title: t("incomingOrders.orderUnavailable"),
|
||||
variant: "destructive",
|
||||
});
|
||||
invalidate();
|
||||
} else {
|
||||
toast({
|
||||
title: t("incomingOrders.actionFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user