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
@@ -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
*/