Task #64: Service Orders — receiver page + admin role toggle
Backend (artifacts/api-server)
- Add admin-only role-toggle endpoints:
- POST /users/:id/roles { roleName } — idempotent, returns UserProfile
- DELETE /users/:id/roles/:roleName — idempotent, returns UserProfile
- Allow assigned receiver to cancel their own claimed order
(received/preparing) in PATCH /orders/:id/status. Owner & admin
rules unchanged.
- New backend test cases: receiver can cancel own assigned order;
another receiver gets 403.
API spec / codegen
- Add AddUserRoleBody schema and the two new role endpoints
under a new "roles" tag in lib/api-spec/openapi.yaml.
- Regenerated api-zod and api-client-react.
Frontend (artifacts/teaboy-os)
- New page src/pages/orders-incoming.tsx at /orders/incoming:
RBAC-gated (admin || order_receiver), shows "My active orders"
and "Awaiting receiver" sections, with claim, mark preparing,
mark completed and cancel buttons. Handles 409 already_claimed.
- Add Inbox button to home top bar, conditional on the same roles.
- Admin users table now has an "Order Receiver" Switch wired to
the new role-toggle hooks.
- Extend use-notifications-socket to invalidate the incoming-orders
query on order_incoming_changed and on notification_created with
type === "order".
- Bilingual locale keys (ar/en) for the new page and admin label.
Tests
- All 25 api-server tests pass (24 existing + 1 new receiver-cancel
case). All 3 teaboy-os e2e tests pass.
Follow-up filed: #67 (notify requester when receiver cancels).
This commit is contained in:
@@ -309,9 +309,16 @@ router.patch(
|
||||
}
|
||||
} else if (next === "cancelled") {
|
||||
const isOwner = existing.userId === userId;
|
||||
const isAssignee = existing.assignedTo === userId;
|
||||
const cancellableByOwner =
|
||||
existing.status === "pending" || existing.status === "received";
|
||||
if (!admin && !(isOwner && cancellableByOwner)) {
|
||||
const cancellableByAssignee =
|
||||
existing.status === "received" || existing.status === "preparing";
|
||||
if (
|
||||
!admin &&
|
||||
!(isOwner && cancellableByOwner) &&
|
||||
!(isAssignee && cancellableByAssignee)
|
||||
) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import { usersTable } from "@workspace/db";
|
||||
import { usersTable, userRolesTable, rolesTable } from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
import {
|
||||
RegisterBody,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UpdateUserParams,
|
||||
UpdateUserBody,
|
||||
DeleteUserParams,
|
||||
AddUserRoleBody,
|
||||
} from "@workspace/api-zod";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
@@ -150,6 +151,90 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
res.json(buildUserProfile(user, roles));
|
||||
});
|
||||
|
||||
router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = GetUserParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const parsed = AddUserRoleBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, params.data.id));
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [role] = await db
|
||||
.select({ id: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
.where(eq(rolesTable.name, parsed.data.roleName));
|
||||
if (!role) {
|
||||
res.status(404).json({ error: "Role not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(userRolesTable)
|
||||
.values({ userId: user.id, roleId: role.id })
|
||||
.onConflictDoNothing();
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
res.json(buildUserProfile(user, roles));
|
||||
});
|
||||
|
||||
router.delete(
|
||||
"/users/:id/roles/:roleName",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = GetUserParams.safeParse({ id: req.params.id });
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const roleName = String(req.params.roleName ?? "").trim();
|
||||
if (!roleName) {
|
||||
res.status(400).json({ error: "roleName required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, params.data.id));
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [role] = await db
|
||||
.select({ id: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
.where(eq(rolesTable.name, roleName));
|
||||
|
||||
if (role) {
|
||||
await db
|
||||
.delete(userRolesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userRolesTable.userId, user.id),
|
||||
eq(userRolesTable.roleId, role.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
res.json(buildUserProfile(user, roles));
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = DeleteUserParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
@@ -215,6 +215,29 @@ test("status transitions matrix: receiver flows + owner/admin cancel rules", asy
|
||||
res = await call(ca, "PATCH", `/orders/${o2.id}/status`, { status: "cancelled" });
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// Assigned receiver CAN cancel their own assigned order while preparing
|
||||
res = await call(co, "POST", "/orders", { serviceId });
|
||||
const o3 = await res.json();
|
||||
createdOrderIds.push(o3.id);
|
||||
res = await call(cr, "PATCH", `/orders/${o3.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" });
|
||||
assert.equal(res.status, 200);
|
||||
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "cancelled" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, "cancelled");
|
||||
|
||||
// A different receiver (not the assignee) cannot cancel
|
||||
res = await call(co, "POST", "/orders", { serviceId });
|
||||
const o4 = await res.json();
|
||||
createdOrderIds.push(o4.id);
|
||||
res = await call(cr, "PATCH", `/orders/${o4.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
const otherRecv = await createUser("recv4", "order_receiver");
|
||||
const cor = await login(otherRecv.username);
|
||||
res = await call(cor, "PATCH", `/orders/${o4.id}/status`, { status: "cancelled" });
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
// Receiver completes original order
|
||||
res = await call(cr, "PATCH", `/orders/${order.id}/status`, {
|
||||
status: "completed",
|
||||
|
||||
@@ -16,6 +16,7 @@ import NotificationsPage from "@/pages/notifications";
|
||||
import AdminPage from "@/pages/admin";
|
||||
import NotesPage from "@/pages/notes";
|
||||
import MyOrdersPage from "@/pages/my-orders";
|
||||
import OrdersIncomingPage from "@/pages/orders-incoming";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -46,6 +47,7 @@ function Router() {
|
||||
<Route path="/admin" component={AdminPage} />
|
||||
<Route path="/notes" component={NotesPage} />
|
||||
<Route path="/my-orders" component={MyOrdersPage} />
|
||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
||||
<Route path="/" component={HomePage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getListNotificationsQueryKey,
|
||||
getGetHomeStatsQueryKey,
|
||||
getListMyServiceOrdersQueryKey,
|
||||
getListIncomingServiceOrdersQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
@@ -28,6 +29,9 @@ export function useNotificationsSocket() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListMyServiceOrdersQueryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,6 +39,15 @@ export function useNotificationsSocket() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListMyServiceOrdersQueryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("order_incoming_changed", () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -140,6 +140,32 @@
|
||||
"completed": "مكتمل",
|
||||
"cancelled": "ملغى"
|
||||
},
|
||||
"incomingOrders": {
|
||||
"title": "الطلبات الواردة",
|
||||
"linkLabel": "الطلبات الواردة",
|
||||
"subtitleUnclaimed": "طلبات بانتظار الاستلام",
|
||||
"subtitleMine": "طلباتي الجارية",
|
||||
"empty": "ما في طلبات حالياً",
|
||||
"emptyHint": "ستظهر هنا الطلبات الجديدة فور وصولها.",
|
||||
"loadError": "تعذّر تحميل الطلبات الواردة.",
|
||||
"retry": "إعادة المحاولة",
|
||||
"noAccess": "ليس لديك صلاحية لاستلام الطلبات.",
|
||||
"back": "رجوع",
|
||||
"ageJustNow": "الآن",
|
||||
"ageMinutes": "منذ {{count}} د",
|
||||
"ageHours": "منذ {{count}} س",
|
||||
"ageDays": "منذ {{count}} يوم",
|
||||
"from": "من",
|
||||
"confirmReceipt": "استلام الطلب",
|
||||
"confirming": "جارٍ التأكيد...",
|
||||
"markPreparing": "بدء التحضير",
|
||||
"markCompleted": "إنجاز",
|
||||
"cancel": "إلغاء",
|
||||
"alreadyClaimed": "استلمه شخص آخر قبلك",
|
||||
"actionFailed": "تعذّر تنفيذ الإجراء",
|
||||
"claimed": "تم استلام الطلب",
|
||||
"marked": "تم تحديث الحالة"
|
||||
},
|
||||
"chat": {
|
||||
"title": "المحادثات",
|
||||
"newMessage": "رسالة جديدة",
|
||||
@@ -285,6 +311,7 @@
|
||||
"roles": "الصلاحيات",
|
||||
"active": "نشط",
|
||||
"issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور",
|
||||
"orderReceiverRole": "مستلم الطلبات",
|
||||
"resetLinkModal": {
|
||||
"title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}",
|
||||
"description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.",
|
||||
|
||||
@@ -140,6 +140,32 @@
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"incomingOrders": {
|
||||
"title": "Incoming Orders",
|
||||
"linkLabel": "Incoming",
|
||||
"subtitleUnclaimed": "Awaiting receiver",
|
||||
"subtitleMine": "My active orders",
|
||||
"empty": "No incoming orders right now",
|
||||
"emptyHint": "New orders will show up here as they arrive.",
|
||||
"loadError": "Could not load incoming orders.",
|
||||
"retry": "Retry",
|
||||
"noAccess": "You don't have permission to receive orders.",
|
||||
"back": "Back",
|
||||
"ageJustNow": "just now",
|
||||
"ageMinutes": "{{count}}m ago",
|
||||
"ageHours": "{{count}}h ago",
|
||||
"ageDays": "{{count}}d ago",
|
||||
"from": "From",
|
||||
"confirmReceipt": "Confirm Receipt",
|
||||
"confirming": "Confirming...",
|
||||
"markPreparing": "Mark Preparing",
|
||||
"markCompleted": "Mark Completed",
|
||||
"cancel": "Cancel",
|
||||
"alreadyClaimed": "Already claimed by someone else",
|
||||
"actionFailed": "Could not complete the action",
|
||||
"claimed": "Order received",
|
||||
"marked": "Status updated"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
"newMessage": "New Message",
|
||||
@@ -282,6 +308,7 @@
|
||||
"roles": "Roles",
|
||||
"active": "Active",
|
||||
"issueResetLink": "Issue password reset link",
|
||||
"orderReceiverRole": "Order Receiver",
|
||||
"resetLinkModal": {
|
||||
"title": "Password reset link for {{username}}",
|
||||
"description": "Share this one-time link with the user. It can only be used once.",
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useAddUserRole,
|
||||
useRemoveUserRole,
|
||||
useGetAppSettings,
|
||||
getGetAppSettingsQueryKey,
|
||||
useUpdateAppSettings,
|
||||
@@ -187,6 +189,8 @@ export default function AdminPage() {
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
const addUserRole = useAddUserRole();
|
||||
const removeUserRole = useRemoveUserRole();
|
||||
const issueResetLink = useAdminIssueResetLink();
|
||||
const [resetLinkUser, setResetLinkUser] = useState<{ username: string; url: string; expiresAt: string } | null>(null);
|
||||
|
||||
@@ -700,6 +704,36 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground select-none">
|
||||
<span>{t("admin.orderReceiverRole")}</span>
|
||||
<Switch
|
||||
checked={u.roles?.includes("order_receiver") ?? false}
|
||||
onCheckedChange={(v) => {
|
||||
const opts = {
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListUsersQueryKey(),
|
||||
}),
|
||||
onError: () =>
|
||||
toast({
|
||||
title: t("common.error"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
};
|
||||
if (v) {
|
||||
addUserRole.mutate(
|
||||
{ id: u.id, data: { roleName: "order_receiver" } },
|
||||
opts,
|
||||
);
|
||||
} else {
|
||||
removeUserRole.mutate(
|
||||
{ id: u.id, roleName: "order_receiver" },
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Switch
|
||||
checked={u.isActive}
|
||||
onCheckedChange={(v) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
Bell,
|
||||
Inbox,
|
||||
LogOut,
|
||||
Globe,
|
||||
Grid2X2,
|
||||
@@ -408,6 +409,8 @@ export default function HomePage() {
|
||||
: (user?.displayNameEn ?? user?.username ?? "");
|
||||
|
||||
const isAdmin = user?.roles?.includes("admin") ?? false;
|
||||
const canReceiveOrders =
|
||||
isAdmin || (user?.roles?.includes("order_receiver") ?? false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
@@ -473,6 +476,15 @@ export default function HomePage() {
|
||||
>
|
||||
<Globe size={18} />
|
||||
</button>
|
||||
{canReceiveOrders && (
|
||||
<button
|
||||
onClick={() => setLocation("/orders/incoming")}
|
||||
aria-label={t("incomingOrders.linkLabel")}
|
||||
className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Inbox size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setLocation("/notifications")}
|
||||
className="relative p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useListIncomingServiceOrders,
|
||||
getListIncomingServiceOrdersQueryKey,
|
||||
useConfirmServiceOrderReceipt,
|
||||
useUpdateServiceOrderStatus,
|
||||
type ServiceOrder,
|
||||
} from "@workspace/api-client-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Inbox,
|
||||
ImageIcon,
|
||||
Clock,
|
||||
CheckCheck,
|
||||
Flame,
|
||||
PackageCheck,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const ORDER_RECEIVER_ROLE = "order_receiver";
|
||||
|
||||
export function userCanReceiveOrders(roles: string[] | undefined | null): boolean {
|
||||
if (!roles) return false;
|
||||
return roles.includes("admin") || roles.includes(ORDER_RECEIVER_ROLE);
|
||||
}
|
||||
|
||||
function relativeAge(iso: string, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.max(0, Math.floor(ms / 60000));
|
||||
if (mins < 1) return t("incomingOrders.ageJustNow");
|
||||
if (mins < 60) return t("incomingOrders.ageMinutes", { count: mins });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return t("incomingOrders.ageHours", { count: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return t("incomingOrders.ageDays", { count: days });
|
||||
}
|
||||
|
||||
function OrderImage({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
if (!src || errored) {
|
||||
return (
|
||||
<div className="w-14 h-14 rounded-lg bg-slate-100 flex items-center justify-center shrink-0">
|
||||
<ImageIcon size={18} className="text-slate-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-14 h-14 rounded-lg object-cover shrink-0"
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: ServiceOrder["status"] }) {
|
||||
const cls = "shrink-0";
|
||||
if (status === "pending") return <Clock size={14} className={cn(cls, "text-slate-500")} />;
|
||||
if (status === "received") return <CheckCheck size={14} className={cn(cls, "text-sky-600")} />;
|
||||
if (status === "preparing") return <Flame size={14} className={cn(cls, "text-amber-600")} />;
|
||||
if (status === "completed") return <PackageCheck size={14} className={cn(cls, "text-emerald-600")} />;
|
||||
return <XCircle size={14} className={cn(cls, "text-rose-600")} />;
|
||||
}
|
||||
|
||||
function IncomingOrderCard({
|
||||
order,
|
||||
meId,
|
||||
onActionDone,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
meId: number;
|
||||
onActionDone: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const claim = useConfirmServiceOrderReceipt();
|
||||
const updateStatus = useUpdateServiceOrderStatus();
|
||||
|
||||
const isMine = order.assignedTo === meId;
|
||||
const serviceName = lang === "ar" ? order.service.nameAr : order.service.nameEn;
|
||||
const requesterName = order.requester
|
||||
? lang === "ar"
|
||||
? (order.requester.displayNameAr ?? order.requester.username)
|
||||
: (order.requester.displayNameEn ?? order.requester.username)
|
||||
: "";
|
||||
const img = resolveServiceImageUrl(order.service.imageUrl);
|
||||
const busy = claim.isPending || updateStatus.isPending;
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
onActionDone();
|
||||
};
|
||||
|
||||
const handleConfirmReceipt = () => {
|
||||
claim.mutate(
|
||||
{ id: order.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("incomingOrders.claimed") });
|
||||
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) {
|
||||
toast({
|
||||
title: t("incomingOrders.alreadyClaimed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("incomingOrders.actionFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSetStatus = (status: "preparing" | "completed" | "cancelled") => {
|
||||
updateStatus.mutate(
|
||||
{ id: order.id, data: { status } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("incomingOrders.marked") });
|
||||
invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t("incomingOrders.actionFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<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">
|
||||
<StatusIcon status={order.status} />
|
||||
<span>{t(`orderStatus.${order.status}`)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{relativeAge(order.createdAt, t)}</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground leading-tight truncate mt-0.5">
|
||||
{serviceName}
|
||||
</h3>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{t("incomingOrders.from")}{" "}
|
||||
<span className="text-foreground/80 font-medium">{requesterName}</span>
|
||||
</div>
|
||||
{order.notes && (
|
||||
<div className="mt-1.5 text-xs text-foreground/80 line-clamp-3 whitespace-pre-wrap">
|
||||
{order.notes}
|
||||
</div>
|
||||
)}
|
||||
</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")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrdersIncomingPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { user } = useAuth();
|
||||
const lang = i18n.language;
|
||||
const isRtl = lang === "ar";
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||
|
||||
const allowed = userCanReceiveOrders(user?.roles);
|
||||
|
||||
const { data: orders, isLoading, isError, refetch } = useListIncomingServiceOrders({
|
||||
query: {
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
enabled: !!user && allowed,
|
||||
},
|
||||
});
|
||||
|
||||
const meId = user?.id ?? -1;
|
||||
const sorted = (orders ?? [])
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
const mine = sorted.filter((o) => o.assignedTo === meId);
|
||||
const unclaimed = sorted.filter((o) => o.status === "pending" && !o.assignedTo);
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={() => setLocation("/")}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("incomingOrders.back")}
|
||||
>
|
||||
<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">
|
||||
{t("incomingOrders.title")}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 pb-8 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" />
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("incomingOrders.noAccess")}
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<div className="text-sm text-rose-700">
|
||||
{t("incomingOrders.loadError")}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{t("incomingOrders.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : mine.length === 0 && unclaimed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<Inbox size={36} className="text-slate-300" />
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("incomingOrders.empty")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("incomingOrders.emptyHint")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{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 flex-col gap-2">
|
||||
{mine.map((o) => (
|
||||
<IncomingOrderCard
|
||||
key={o.id}
|
||||
order={o}
|
||||
meId={meId}
|
||||
onActionDone={refetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{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 flex-col gap-2">
|
||||
{unclaimed.map((o) => (
|
||||
<IncomingOrderCard
|
||||
key={o.id}
|
||||
order={o}
|
||||
meId={meId}
|
||||
onActionDone={refetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,11 @@ export interface UpdateUserBody {
|
||||
preferredLanguage?: string;
|
||||
}
|
||||
|
||||
export interface AddUserRoleBody {
|
||||
/** @minLength 1 */
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: number;
|
||||
slug: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
|
||||
import type {
|
||||
AddParticipantsBody,
|
||||
AddUserRoleBody,
|
||||
AdminAppOpensByApp,
|
||||
AdminAppOpensByUser,
|
||||
AdminResetLinkResponse,
|
||||
@@ -4378,6 +4379,178 @@ export const useDeleteUser = <
|
||||
return useMutation(getDeleteUserMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Idempotently add a role to a user (admin)
|
||||
*/
|
||||
export const getAddUserRoleUrl = (id: number) => {
|
||||
return `/api/users/${id}/roles`;
|
||||
};
|
||||
|
||||
export const addUserRole = async (
|
||||
id: number,
|
||||
addUserRoleBody: AddUserRoleBody,
|
||||
options?: RequestInit,
|
||||
): Promise<UserProfile> => {
|
||||
return customFetch<UserProfile>(getAddUserRoleUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(addUserRoleBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getAddUserRoleMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addUserRole>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddUserRoleBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addUserRole>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddUserRoleBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["addUserRole"];
|
||||
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 addUserRole>>,
|
||||
{ id: number; data: BodyType<AddUserRoleBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return addUserRole(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AddUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof addUserRole>>
|
||||
>;
|
||||
export type AddUserRoleMutationBody = BodyType<AddUserRoleBody>;
|
||||
export type AddUserRoleMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Idempotently add a role to a user (admin)
|
||||
*/
|
||||
export const useAddUserRole = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addUserRole>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddUserRoleBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof addUserRole>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddUserRoleBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getAddUserRoleMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Idempotently remove a role from a user (admin)
|
||||
*/
|
||||
export const getRemoveUserRoleUrl = (id: number, roleName: string) => {
|
||||
return `/api/users/${id}/roles/${roleName}`;
|
||||
};
|
||||
|
||||
export const removeUserRole = async (
|
||||
id: number,
|
||||
roleName: string,
|
||||
options?: RequestInit,
|
||||
): Promise<UserProfile> => {
|
||||
return customFetch<UserProfile>(getRemoveUserRoleUrl(id, roleName), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getRemoveUserRoleMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeUserRole>>,
|
||||
TError,
|
||||
{ id: number; roleName: string },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeUserRole>>,
|
||||
TError,
|
||||
{ id: number; roleName: string },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["removeUserRole"];
|
||||
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 removeUserRole>>,
|
||||
{ id: number; roleName: string }
|
||||
> = (props) => {
|
||||
const { id, roleName } = props ?? {};
|
||||
|
||||
return removeUserRole(id, roleName, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RemoveUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof removeUserRole>>
|
||||
>;
|
||||
|
||||
export type RemoveUserRoleMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Idempotently remove a role from a user (admin)
|
||||
*/
|
||||
export const useRemoveUserRole = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeUserRole>>,
|
||||
TError,
|
||||
{ id: number; roleName: string },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof removeUserRole>>,
|
||||
TError,
|
||||
{ id: number; roleName: string },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRemoveUserRoleMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get home screen stats
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,8 @@ tags:
|
||||
description: Notifications
|
||||
- name: users
|
||||
description: User management
|
||||
- name: roles
|
||||
description: Role assignment
|
||||
- name: stats
|
||||
description: Dashboard stats
|
||||
- name: storage
|
||||
@@ -1123,6 +1125,73 @@ paths:
|
||||
"204":
|
||||
description: Deleted
|
||||
|
||||
/users/{id}/roles:
|
||||
post:
|
||||
operationId: addUserRole
|
||||
tags: [roles]
|
||||
summary: Idempotently add a role to a user (admin)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AddUserRoleBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Role added (or already present)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserProfile"
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: User or role not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/users/{id}/roles/{roleName}:
|
||||
delete:
|
||||
operationId: removeUserRole
|
||||
tags: [roles]
|
||||
summary: Idempotently remove a role from a user (admin)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: roleName
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Role removed (or already absent)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserProfile"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# Stats
|
||||
/stats/home:
|
||||
get:
|
||||
@@ -1551,6 +1620,15 @@ components:
|
||||
preferredLanguage:
|
||||
type: string
|
||||
|
||||
AddUserRoleBody:
|
||||
type: object
|
||||
properties:
|
||||
roleName:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- roleName
|
||||
|
||||
App:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1405,6 +1405,69 @@ export const DeleteUserParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Idempotently add a role to a user (admin)
|
||||
*/
|
||||
export const AddUserRoleParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const AddUserRoleBody = zod.object({
|
||||
roleName: zod.string().min(1),
|
||||
});
|
||||
|
||||
export const AddUserRoleResponse = zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
email: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
clockHour12: zod.boolean().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
createdAt: zod.coerce.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Idempotently remove a role from a user (admin)
|
||||
*/
|
||||
export const RemoveUserRoleParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
roleName: zod.coerce.string(),
|
||||
});
|
||||
|
||||
export const RemoveUserRoleResponse = zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
email: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod.string(),
|
||||
clockStyle: zod
|
||||
.union([
|
||||
zod
|
||||
.enum(["full", "digital", "digital-no-seconds", "analog", "minimal"])
|
||||
.describe("Per-user home-screen clock style preference"),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
clockHour12: zod.boolean().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isActive: zod.boolean(),
|
||||
roles: zod.array(zod.string()),
|
||||
createdAt: zod.coerce.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get home screen stats
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user