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:
riyadhafraa
2026-05-20 11:51:28 +00:00
parent f02305fe9f
commit 376918f983
8 changed files with 622 additions and 43 deletions
@@ -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) {
+6 -1
View File
@@ -114,7 +114,12 @@
"placing": "جارٍ الإرسال...",
"orderPlaced": "تم إرسال طلبك",
"orderFailed": "تعذّر إرسال الطلب",
"myOrdersLink": "طلباتي"
"myOrdersLink": "طلباتي",
"editMode": {
"hint": "اسحب لإعادة الترتيب",
"done": "تم",
"saveFailed": "تعذّر حفظ الترتيب الجديد"
}
},
"myOrders": {
"title": "طلباتي",
+6 -1
View File
@@ -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",
+373 -30
View File
@@ -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,11 +410,33 @@ 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 = () => {
<>
{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"
>
{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,
@@ -101,35 +444,35 @@ export default function ServicesPage() {
nameEn: service.nameEn,
imageUrl: service.imageUrl,
});
};
}}
onLongPress={onLongPress}
unavailableLabel={t("services.unavailable")}
/>
))}
</div>
</SortableContext>
</DndContext>
</>
)}
</div>
return (
{editMode && (
<div className="fixed bottom-24 inset-x-0 flex justify-center z-40 pointer-events-none">
<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"
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}
>
<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>
{reorderMutation.isPending
? t("common.loading")
: t("services.editMode.done")}
</button>
);
})}
</div>
)}
</div>
<OrderServiceModal
open={orderTarget !== null}
@@ -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;
+93
View File
@@ -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
*/
+45
View File
@@ -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:
+23
View File
@@ -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
*/