diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 1ec7314b..9890bf52 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -37,7 +37,9 @@ async function getVisibleAppsForUser(userId: number): Promise 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 r.app); diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 63c7b8a3..c3ec4381 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -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; diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 02c228a5..2692ad0f 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -180,6 +180,7 @@ "markCompleted": "إنجاز", "cancel": "إلغاء", "alreadyClaimed": "استلمه شخص آخر قبلك", + "orderUnavailable": "هذا الطلب لم يعد متاحاً", "actionFailed": "تعذّر تنفيذ الإجراء", "claimed": "تم استلام الطلب", "marked": "تم تحديث الحالة", @@ -296,6 +297,7 @@ "manageApps": "إدارة التطبيقات", "manageServices": "إدارة الخدمات", "manageUsers": "إدارة المستخدمين", + "appDisabled": "معطّل", "addApp": "إضافة تطبيق", "addService": "إضافة خدمة", "addUser": "إضافة مستخدم", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 6fc245ed..e4bd556d 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index eee2fc3b..7d87f04f 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -637,15 +637,22 @@ export default function AdminPage() { {apps?.map((app) => ( -
+
-
- {lang === "ar" ? app.nameAr : app.nameEn} +
+
+ {lang === "ar" ? app.nameAr : app.nameEn} +
+ {!app.isActive && ( + + {t("admin.appDisabled")} + + )}
{app.route}
diff --git a/artifacts/tx-os/src/pages/home.tsx b/artifacts/tx-os/src/pages/home.tsx index 4ae5e6ec..43881f33 100644 --- a/artifacts/tx-os/src/pages/home.tsx +++ b/artifacts/tx-os/src/pages/home.tsx @@ -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]); diff --git a/artifacts/tx-os/src/pages/orders-incoming.tsx b/artifacts/tx-os/src/pages/orders-incoming.tsx index e9ae06cb..41142d60 100644 --- a/artifacts/tx-os/src/pages/orders-incoming.tsx +++ b/artifacts/tx-os/src/pages/orders-incoming.tsx @@ -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", + }); + } }, }, ); diff --git a/attached_assets/image_1777280177090.png b/attached_assets/image_1777280177090.png new file mode 100644 index 00000000..ea3d41f9 Binary files /dev/null and b/attached_assets/image_1777280177090.png differ