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:
Riyadh
2026-04-27 09:06:23 +00:00
parent 14ce113e70
commit 57292caa07
7 changed files with 92 additions and 31 deletions
+12 -6
View File
@@ -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;
+2
View File
@@ -180,6 +180,7 @@
"markCompleted": "إنجاز",
"cancel": "إلغاء",
"alreadyClaimed": "استلمه شخص آخر قبلك",
"orderUnavailable": "هذا الطلب لم يعد متاحاً",
"actionFailed": "تعذّر تنفيذ الإجراء",
"claimed": "تم استلام الطلب",
"marked": "تم تحديث الحالة",
@@ -296,6 +297,7 @@
"manageApps": "إدارة التطبيقات",
"manageServices": "إدارة الخدمات",
"manageUsers": "إدارة المستخدمين",
"appDisabled": "معطّل",
"addApp": "إضافة تطبيق",
"addService": "إضافة خدمة",
"addUser": "إضافة مستخدم",
+2
View File
@@ -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",
+10 -3
View File
@@ -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>
+4 -3
View File
@@ -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]);
+37 -13
View File
@@ -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",
});
}
},
},
);