Drag-to-reorder service cards (iOS jiggle mode)
Long-press a service tile (~500 ms) → grid enters edit mode: every
card jiggles, a floating "تم / Done" pill appears, and cards become
draggable via @dnd-kit. Drop on another tile to reorder. Tap Done or
outside the grid to persist; the new order is shared globally via
servicesTable.sortOrder.
Backend
- POST /api/services/reorder (admin-gated, requireAdmin)
- Validates full-set match (no missing/dup/unknown IDs)
- Transactional sortOrder rewrite — no partial writes
- Route registered before /services/:id to avoid path-param collision
- OpenAPI op + ReorderServicesBody Zod schema → regenerated client
Frontend
- artifacts/tx-os/src/pages/services.tsx rewritten
- PointerSensor (distance 6) + TouchSensor (delay 150 ms) — long-press
and tap-to-open stay distinct
- touch-none only applied while in edit mode so normal scrolling is
unaffected outside it
- exitEditMode awaits mutation + invalidate before flipping editMode,
preventing snap-back from stale react-query cache
- exitingRef guard against duplicate exit calls (Done button +
outside-tap handler firing for the same gesture)
- Non-admins can enter edit mode (haptic affordance) but local reorder
is silently reverted on exit
- i18n: services.editMode.{done,hint,saveFailed} added to ar.json/en.json
Pre-existing unrelated typecheck errors in push.ts and
executive-meeting-font-settings.ts are not touched by this change.
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
GetServiceParams,
|
||||
UpdateServiceParams,
|
||||
DeleteServiceParams,
|
||||
ReorderServicesBody,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -71,6 +72,55 @@ router.get("/service-categories", requireAuth, async (_req, res): Promise<void>
|
||||
res.json(categories);
|
||||
});
|
||||
|
||||
// Bulk reorder. Body carries the desired full ordering as a list of
|
||||
// service ids; the server validates it covers the entire catalogue
|
||||
// exactly once (no missing/duplicate/unknown ids) and then rewrites
|
||||
// `sort_order` to the 0-based index. Wrapped in a transaction so the
|
||||
// catalogue never observes a partially-applied order. Declared BEFORE
|
||||
// the `/services/:id` routes so Express doesn't try to parse "reorder"
|
||||
// as a numeric id and return a 400 from GetServiceParams.
|
||||
router.post("/services/reorder", requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = ReorderServicesBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { orderedIds } = parsed.data;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: servicesTable.id })
|
||||
.from(servicesTable);
|
||||
const existingIds = new Set(existing.map((s) => s.id));
|
||||
|
||||
if (orderedIds.length !== existingIds.size) {
|
||||
res.status(400).json({
|
||||
error: "orderedIds must contain every service id exactly once",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
for (const id of orderedIds) {
|
||||
if (!existingIds.has(id) || seen.has(id)) {
|
||||
res.status(400).json({
|
||||
error: "orderedIds must contain every service id exactly once",
|
||||
});
|
||||
return;
|
||||
}
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx
|
||||
.update(servicesTable)
|
||||
.set({ sortOrder: i })
|
||||
.where(eq(servicesTable.id, orderedIds[i]));
|
||||
}
|
||||
});
|
||||
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.get("/services/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = GetServiceParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
@@ -114,7 +114,12 @@
|
||||
"placing": "جارٍ الإرسال...",
|
||||
"orderPlaced": "تم إرسال طلبك",
|
||||
"orderFailed": "تعذّر إرسال الطلب",
|
||||
"myOrdersLink": "طلباتي"
|
||||
"myOrdersLink": "طلباتي",
|
||||
"editMode": {
|
||||
"hint": "اسحب لإعادة الترتيب",
|
||||
"done": "تم",
|
||||
"saveFailed": "تعذّر حفظ الترتيب الجديد"
|
||||
}
|
||||
},
|
||||
"myOrders": {
|
||||
"title": "طلباتي",
|
||||
|
||||
@@ -114,7 +114,12 @@
|
||||
"placing": "Sending...",
|
||||
"orderPlaced": "Order placed",
|
||||
"orderFailed": "Could not place the order",
|
||||
"myOrdersLink": "My Orders"
|
||||
"myOrdersLink": "My Orders",
|
||||
"editMode": {
|
||||
"hint": "Drag to rearrange",
|
||||
"done": "Done",
|
||||
"saveFailed": "Could not save the new order"
|
||||
}
|
||||
},
|
||||
"myOrders": {
|
||||
"title": "My Orders",
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
useListServices,
|
||||
getListServicesQueryKey,
|
||||
useReorderServices,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, ArrowLeft, Coffee, ImageIcon, Inbox } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { OrderServiceModal } from "@/components/order-service-modal";
|
||||
import { AppDock } from "@/components/app-dock";
|
||||
@@ -19,6 +39,14 @@ type CatalogService = {
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
type ServiceRow = {
|
||||
id: number;
|
||||
nameAr: string;
|
||||
nameEn: string;
|
||||
imageUrl?: string | null;
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const showImage = src && !errored;
|
||||
@@ -30,6 +58,7 @@ function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) {
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setErrored(true)}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon size={24} className="text-slate-300" />
|
||||
@@ -38,22 +67,310 @@ function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type SortableServiceCardProps = {
|
||||
service: ServiceRow;
|
||||
lang: string;
|
||||
editMode: boolean;
|
||||
isDragging: boolean;
|
||||
onTapInView: () => void;
|
||||
onLongPress: () => void;
|
||||
unavailableLabel: string;
|
||||
};
|
||||
|
||||
function SortableServiceCard({
|
||||
service,
|
||||
lang,
|
||||
editMode,
|
||||
isDragging,
|
||||
onTapInView,
|
||||
onLongPress,
|
||||
unavailableLabel,
|
||||
}: SortableServiceCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: thisDragging,
|
||||
} = useSortable({ id: service.id, disabled: !editMode });
|
||||
|
||||
const name = lang === "ar" ? service.nameAr : service.nameEn;
|
||||
const resolvedImage = resolveServiceImageUrl(service.imageUrl);
|
||||
|
||||
// Long-press detection — only fires when NOT already in edit mode.
|
||||
// We track pointer position so a scroll/drag of more than 10 px
|
||||
// cancels the timer (matches iOS behaviour and prevents accidental
|
||||
// entry during fast scrolling).
|
||||
const pressTimer = useRef<number | null>(null);
|
||||
const pressStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const longPressed = useRef(false);
|
||||
|
||||
const clearTimer = () => {
|
||||
if (pressTimer.current !== null) {
|
||||
window.clearTimeout(pressTimer.current);
|
||||
pressTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (editMode) return;
|
||||
longPressed.current = false;
|
||||
pressStart.current = { x: e.clientX, y: e.clientY };
|
||||
pressTimer.current = window.setTimeout(() => {
|
||||
longPressed.current = true;
|
||||
pressTimer.current = null;
|
||||
try {
|
||||
navigator.vibrate?.(10);
|
||||
} catch {
|
||||
/* not supported — ignore */
|
||||
}
|
||||
onLongPress();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (editMode || pressStart.current === null) return;
|
||||
const dx = e.clientX - pressStart.current.x;
|
||||
const dy = e.clientY - pressStart.current.y;
|
||||
if (dx * dx + dy * dy > 100) clearTimer();
|
||||
};
|
||||
|
||||
const handlePointerEnd = () => {
|
||||
clearTimer();
|
||||
pressStart.current = null;
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Swallow the click that follows a long-press so the order modal
|
||||
// doesn't open the instant we enter edit mode.
|
||||
if (longPressed.current) {
|
||||
longPressed.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (editMode) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onTapInView();
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: thisDragging ? 20 : undefined,
|
||||
} as React.CSSProperties;
|
||||
|
||||
// Jiggle every card while editing, except the one being actively
|
||||
// dragged (its own motion already conveys intent and the rotation
|
||||
// would fight the drag transform).
|
||||
const jiggleClass = editMode && !thisDragging ? "animate-services-jiggle" : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
disabled={!editMode && !service.isAvailable}
|
||||
aria-label={name}
|
||||
style={style}
|
||||
{...(editMode ? listeners : {})}
|
||||
{...(editMode ? attributes : {})}
|
||||
className={`glass-panel rounded-lg overflow-hidden flex flex-col card-hover text-start select-none disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${editMode ? "touch-none" : ""} ${jiggleClass} ${isDragging ? "opacity-90" : ""}`}
|
||||
>
|
||||
<ServiceCardImage src={resolvedImage} alt={name} />
|
||||
<div className="p-2 flex flex-col gap-0.5 flex-1">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<h3 className="text-xs font-semibold text-foreground leading-tight truncate">
|
||||
{name}
|
||||
</h3>
|
||||
{!service.isAvailable && (
|
||||
<Badge variant="secondary" className="text-[9px] px-1 py-0 shrink-0">
|
||||
{unavailableLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const lang = i18n.language;
|
||||
const isRtl = lang === "ar";
|
||||
const isAdmin = user?.roles?.includes("admin") ?? false;
|
||||
|
||||
const { data: services, isLoading } = useListServices({
|
||||
query: { queryKey: getListServicesQueryKey() },
|
||||
});
|
||||
|
||||
const reorderMutation = useReorderServices();
|
||||
|
||||
const [orderTarget, setOrderTarget] = useState<CatalogService | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
// Local working copy of the list — diverges from the server data
|
||||
// only while the user is actively reordering, so query refetches
|
||||
// don't clobber an in-progress drag session.
|
||||
const [orderedIds, setOrderedIds] = useState<number[] | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||
|
||||
// Sync local order from server data while NOT editing. Once the
|
||||
// user enters edit mode we hold the order locally until they tap
|
||||
// Done; the mutation's invalidation then refills from the server.
|
||||
useEffect(() => {
|
||||
if (!editMode && services) {
|
||||
setOrderedIds(services.map((s) => s.id));
|
||||
}
|
||||
}, [services, editMode]);
|
||||
|
||||
const orderedRows: ServiceRow[] = useMemo(() => {
|
||||
if (!services) return [];
|
||||
const byId = new Map(services.map((s) => [s.id, s]));
|
||||
const ids = orderedIds ?? services.map((s) => s.id);
|
||||
const rows: ServiceRow[] = [];
|
||||
for (const id of ids) {
|
||||
const s = byId.get(id);
|
||||
if (s) rows.push(s);
|
||||
}
|
||||
// Append any services that arrived mid-edit (admin added one in
|
||||
// another tab) so the grid never loses cards on refetch.
|
||||
for (const s of services) {
|
||||
if (!ids.includes(s.id)) rows.push(s);
|
||||
}
|
||||
return rows;
|
||||
}, [services, orderedIds]);
|
||||
|
||||
// Touch sensor uses delay so a tap-to-open isn't misread as a drag;
|
||||
// pointer sensor needs only the tiny activation distance so mouse
|
||||
// drags feel responsive on the desktop preview.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 8 },
|
||||
}),
|
||||
);
|
||||
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||
|
||||
// Prevents two concurrent exits (e.g. the floating Done button and
|
||||
// the document-level outside-tap handler both firing for the same
|
||||
// gesture). Without this we'd see double POSTs to /services/reorder.
|
||||
const exitingRef = useRef(false);
|
||||
|
||||
const exitEditMode = async () => {
|
||||
if (!editMode || exitingRef.current) return;
|
||||
exitingRef.current = true;
|
||||
setActiveDragId(null);
|
||||
|
||||
const serverIds = (services ?? []).map((s) => s.id);
|
||||
const localIds = orderedIds ?? serverIds;
|
||||
const changed =
|
||||
localIds.length !== serverIds.length ||
|
||||
localIds.some((id, i) => id !== serverIds[i]);
|
||||
|
||||
try {
|
||||
if (!changed) {
|
||||
setEditMode(false);
|
||||
return;
|
||||
}
|
||||
if (!isAdmin) {
|
||||
// Non-admins can enter edit mode for the haptic affordance but
|
||||
// can't persist — silently revert.
|
||||
setOrderedIds(serverIds);
|
||||
setEditMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await reorderMutation.mutateAsync({ data: { orderedIds: localIds } });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getListServicesQueryKey(),
|
||||
});
|
||||
// Only drop edit mode after the refetch resolves so the
|
||||
// sync-from-server effect doesn't briefly rehydrate from the
|
||||
// stale pre-mutation cache and snap the order back.
|
||||
setEditMode(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t("services.editMode.saveFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
setOrderedIds(serverIds);
|
||||
setEditMode(false);
|
||||
}
|
||||
} finally {
|
||||
exitingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onLongPress = () => {
|
||||
if (!editMode) setEditMode(true);
|
||||
};
|
||||
|
||||
const onDragStart = (e: DragStartEvent) => {
|
||||
setActiveDragId(Number(e.active.id));
|
||||
};
|
||||
|
||||
const onDragEnd = (e: DragEndEvent) => {
|
||||
setActiveDragId(null);
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
setOrderedIds((prev) => {
|
||||
const base = prev ?? (services ?? []).map((s) => s.id);
|
||||
const from = base.indexOf(Number(active.id));
|
||||
const to = base.indexOf(Number(over.id));
|
||||
if (from < 0 || to < 0) return base;
|
||||
return arrayMove(base, from, to);
|
||||
});
|
||||
};
|
||||
|
||||
// Tap outside the grid (on the empty page area) exits edit mode.
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!editMode) return;
|
||||
const onDocPointerDown = (ev: PointerEvent) => {
|
||||
const target = ev.target as Node | null;
|
||||
if (target && gridRef.current && !gridRef.current.contains(target)) {
|
||||
// Defer so the click on the Done button (which sits outside
|
||||
// the grid) still gets to fire its own handler first.
|
||||
setTimeout(() => exitEditMode(), 0);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onDocPointerDown);
|
||||
return () => document.removeEventListener("pointerdown", onDocPointerDown);
|
||||
// exitEditMode closes over editMode/orderedIds — re-bind each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editMode, orderedIds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
{/* Inline keyframes — no Tailwind plugin available for arbitrary
|
||||
keyframes and we'd rather not pull tailwindcss-animate just
|
||||
for this one effect. Kept here so the page is self-contained. */}
|
||||
<style>{`
|
||||
@keyframes services-jiggle {
|
||||
0% { transform: rotate(-1.2deg); }
|
||||
50% { transform: rotate(1.2deg); }
|
||||
100% { transform: rotate(-1.2deg); }
|
||||
}
|
||||
.animate-services-jiggle { animation: services-jiggle 0.25s ease-in-out infinite; transform-origin: center; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-services-jiggle { animation: none; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<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
|
||||
@@ -64,7 +381,9 @@ export default function ServicesPage() {
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Coffee size={20} className="text-amber-400" />
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("services.title")}</h1>
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{t("services.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="ms-auto">
|
||||
<Button
|
||||
@@ -74,7 +393,9 @@ export default function ServicesPage() {
|
||||
onClick={() => setLocation("/my-orders")}
|
||||
>
|
||||
<Inbox size={16} />
|
||||
<span className="hidden sm:inline">{t("services.myOrdersLink")}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{t("services.myOrdersLink")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,48 +410,70 @@ export default function ServicesPage() {
|
||||
{t("services.noServices")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-2">
|
||||
{services.map((service) => {
|
||||
const name = lang === "ar" ? service.nameAr : service.nameEn;
|
||||
const resolvedImage = resolveServiceImageUrl(service.imageUrl);
|
||||
const openOrder = () => {
|
||||
if (!service.isAvailable) return;
|
||||
setOrderTarget({
|
||||
id: service.id,
|
||||
nameAr: service.nameAr,
|
||||
nameEn: service.nameEn,
|
||||
imageUrl: service.imageUrl,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={service.id}
|
||||
type="button"
|
||||
onClick={openOrder}
|
||||
disabled={!service.isAvailable}
|
||||
aria-label={name}
|
||||
className="glass-panel rounded-lg overflow-hidden flex flex-col card-hover text-start disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
<>
|
||||
{editMode && (
|
||||
<div className="text-center text-xs text-muted-foreground mb-3">
|
||||
{t("services.editMode.hint")}
|
||||
</div>
|
||||
)}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedRows.map((s) => s.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-2"
|
||||
>
|
||||
<ServiceCardImage src={resolvedImage} alt={name} />
|
||||
<div className="p-2 flex flex-col gap-0.5 flex-1">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<h3 className="text-xs font-semibold text-foreground leading-tight truncate">{name}</h3>
|
||||
{!service.isAvailable && (
|
||||
<Badge variant="secondary" className="text-[9px] px-1 py-0 shrink-0">
|
||||
{t("services.unavailable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{orderedRows.map((service) => (
|
||||
<SortableServiceCard
|
||||
key={service.id}
|
||||
service={service}
|
||||
lang={lang}
|
||||
editMode={editMode}
|
||||
isDragging={activeDragId === service.id}
|
||||
onTapInView={() => {
|
||||
if (!service.isAvailable) return;
|
||||
setOrderTarget({
|
||||
id: service.id,
|
||||
nameAr: service.nameAr,
|
||||
nameEn: service.nameEn,
|
||||
imageUrl: service.imageUrl,
|
||||
});
|
||||
}}
|
||||
onLongPress={onLongPress}
|
||||
unavailableLabel={t("services.unavailable")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="fixed bottom-24 inset-x-0 flex justify-center z-40 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exitEditMode();
|
||||
}}
|
||||
className="pointer-events-auto glass-panel rounded-full px-6 py-2 text-sm font-semibold text-foreground shadow-lg hover:bg-slate-100"
|
||||
disabled={reorderMutation.isPending}
|
||||
>
|
||||
{reorderMutation.isPending
|
||||
? t("common.loading")
|
||||
: t("services.editMode.done")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OrderServiceModal
|
||||
open={orderTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
@@ -643,6 +643,21 @@ export interface UpdateServiceBody {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* New global service order. `orderedIds` must contain every
|
||||
existing service id exactly once. The server sets each
|
||||
service's `sortOrder` to its position in the list (0-based),
|
||||
so a later GET /services returns them in the same order.
|
||||
|
||||
*/
|
||||
export interface ReorderServicesBody {
|
||||
/**
|
||||
* @minItems 1
|
||||
* @maxItems 1000
|
||||
*/
|
||||
orderedIds: number[];
|
||||
}
|
||||
|
||||
export interface ServiceCategory {
|
||||
id: number;
|
||||
nameAr: string;
|
||||
|
||||
@@ -89,6 +89,7 @@ import type {
|
||||
PermissionAuditList,
|
||||
ReceivedNote,
|
||||
RegisterBody,
|
||||
ReorderServicesBody,
|
||||
ReplaceRolePermissionsBody,
|
||||
ReplyToNoteBody,
|
||||
RequestUploadUrlBody,
|
||||
@@ -2660,6 +2661,98 @@ export const useCreateService = <
|
||||
return useMutation(getCreateServiceMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a new global `sortOrder` for the service catalogue by
|
||||
applying the index of each id in `orderedIds`. Admin-only.
|
||||
The body must contain every existing service id exactly once;
|
||||
partial reorders are rejected with 400 so the client can never
|
||||
accidentally drop services from the sort plan.
|
||||
|
||||
* @summary Replace the global service sort order (admin)
|
||||
*/
|
||||
export const getReorderServicesUrl = () => {
|
||||
return `/api/services/reorder`;
|
||||
};
|
||||
|
||||
export const reorderServices = async (
|
||||
reorderServicesBody: ReorderServicesBody,
|
||||
options?: RequestInit,
|
||||
): Promise<void> => {
|
||||
return customFetch<void>(getReorderServicesUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(reorderServicesBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getReorderServicesMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof reorderServices>>,
|
||||
TError,
|
||||
{ data: BodyType<ReorderServicesBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof reorderServices>>,
|
||||
TError,
|
||||
{ data: BodyType<ReorderServicesBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["reorderServices"];
|
||||
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 reorderServices>>,
|
||||
{ data: BodyType<ReorderServicesBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return reorderServices(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ReorderServicesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof reorderServices>>
|
||||
>;
|
||||
export type ReorderServicesMutationBody = BodyType<ReorderServicesBody>;
|
||||
export type ReorderServicesMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Replace the global service sort order (admin)
|
||||
*/
|
||||
export const useReorderServices = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof reorderServices>>,
|
||||
TError,
|
||||
{ data: BodyType<ReorderServicesBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof reorderServices>>,
|
||||
TError,
|
||||
{ data: BodyType<ReorderServicesBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getReorderServicesMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get service by ID
|
||||
*/
|
||||
|
||||
@@ -730,6 +730,33 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Service"
|
||||
|
||||
/services/reorder:
|
||||
post:
|
||||
operationId: reorderServices
|
||||
tags: [services]
|
||||
summary: Replace the global service sort order (admin)
|
||||
description: |
|
||||
Persists a new global `sortOrder` for the service catalogue by
|
||||
applying the index of each id in `orderedIds`. Admin-only.
|
||||
The body must contain every existing service id exactly once;
|
||||
partial reorders are rejected with 400 so the client can never
|
||||
accidentally drop services from the sort plan.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ReorderServicesBody"
|
||||
responses:
|
||||
"204":
|
||||
description: Sort order updated
|
||||
"400":
|
||||
description: Validation error or mismatched id set
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/services/{id}:
|
||||
get:
|
||||
operationId: getService
|
||||
@@ -4031,6 +4058,24 @@ components:
|
||||
sortOrder:
|
||||
type: integer
|
||||
|
||||
ReorderServicesBody:
|
||||
type: object
|
||||
description: |
|
||||
New global service order. `orderedIds` must contain every
|
||||
existing service id exactly once. The server sets each
|
||||
service's `sortOrder` to its position in the list (0-based),
|
||||
so a later GET /services returns them in the same order.
|
||||
properties:
|
||||
orderedIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1000
|
||||
items:
|
||||
type: integer
|
||||
minimum: 1
|
||||
required:
|
||||
- orderedIds
|
||||
|
||||
ServiceCategory:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1117,6 +1117,29 @@ export const CreateServiceBody = zod.object({
|
||||
sortOrder: zod.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Persists a new global `sortOrder` for the service catalogue by
|
||||
applying the index of each id in `orderedIds`. Admin-only.
|
||||
The body must contain every existing service id exactly once;
|
||||
partial reorders are rejected with 400 so the client can never
|
||||
accidentally drop services from the sort plan.
|
||||
|
||||
* @summary Replace the global service sort order (admin)
|
||||
*/
|
||||
|
||||
export const reorderServicesBodyOrderedIdsMax = 1000;
|
||||
|
||||
export const ReorderServicesBody = zod
|
||||
.object({
|
||||
orderedIds: zod
|
||||
.array(zod.number().min(1))
|
||||
.min(1)
|
||||
.max(reorderServicesBodyOrderedIdsMax),
|
||||
})
|
||||
.describe(
|
||||
"New global service order. `orderedIds` must contain every\nexisting service id exactly once. The server sets each\nservice's `sortOrder` to its position in the list (0-based),\nso a later GET \/services returns them in the same order.\n",
|
||||
);
|
||||
|
||||
/**
|
||||
* @summary Get service by ID
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user