Task #183: clickable dependency counts on admin Apps/Services/Users

Turned the inline dependency-count badges on each admin row into
focusable, keyboard-accessible buttons that open a drill-in modal
listing the actual rows behind the count.

Backend (artifacts/api-server/src/routes):
  - apps.ts: GET /admin/apps/:id/dependents/{groups,restrictions,opens}
  - services.ts: GET /admin/services/:id/dependents/orders
  - users.ts: GET /admin/users/:id/dependents/{notes,orders,conversations,messages}
  All paginated (limit default 50, max 200; offset) returning
  {items, totalCount, limit, offset, nextOffset}. Restrictions joins
  the role names that include each required permission.

OpenAPI:
  - Added 8 path operations + 16 schemas (Item + Page) and re-ran
    `pnpm --filter @workspace/api-spec run codegen`.

Frontend (artifacts/tx-os/src/pages/admin.tsx):
  - New DependencyDrillIn component reuses DrillInShell (Escape +
    backdrop close, RTL/LTR safe) and the existing LoadMoreSection
    pagination pattern from AppOpensDrillIn.
  - Each count part in Apps, Services, and Users panels is now a
    <button> with a unique data-testid (e.g. app-counts-groups-213,
    user-counts-messages-5) and an aria-label that reads
    "View {count}".
  - AdminPage owns dependencyTarget state for Apps/Services counts;
    UsersPanel owns its own (it already encapsulates user list).

Translations:
  - Added admin.dependents.* (titles, subtitles, empty/error/load
    labels, conversation/order/message helper strings, order status
    enum) to en.json and ar.json.

Verification: - tx-os typecheck clean; api-server has only the pre-existing
    executive-meetings.ts errors (untouched).
  - e2e tested via runTest: login as admin, opened Apps panel,
    drilled into groups + opens (Load more grew 50→100), drilled into
    Tea orders, drilled into user 5 conversations and messages,
    switched to Arabic/RTL and re-opened the Groups drill-in to
    confirm Arabic rendering with no raw i18n keys.
Replit-Task-Id: fe96a05b-325f-4e1f-901b-3a2235fb24b5
This commit is contained in:
riyadhafraa
2026-05-01 07:32:11 +00:00
parent b8ffe460ef
commit c0e55752a3
11 changed files with 3246 additions and 15 deletions
+352
View File
@@ -2884,3 +2884,355 @@ export const ExportAuditLogsCsvQueryParams = zod.object({
"Restrict the export to entries written by a specific acting user.\n",
),
});
/**
* Returns the groups behind the inline "<n> groups" badge on the
admin Apps row, paginated. Admin only.
* @summary List groups granting access to an app
*/
export const GetAdminAppDependentGroupsParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminAppDependentGroupsQueryLimitDefault = 50;
export const getAdminAppDependentGroupsQueryLimitMax = 200;
export const getAdminAppDependentGroupsQueryOffsetDefault = 0;
export const getAdminAppDependentGroupsQueryOffsetMin = 0;
export const GetAdminAppDependentGroupsQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminAppDependentGroupsQueryLimitMax)
.default(getAdminAppDependentGroupsQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminAppDependentGroupsQueryOffsetMin)
.default(getAdminAppDependentGroupsQueryOffsetDefault),
});
export const GetAdminAppDependentGroupsResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullable(),
descriptionEn: zod.string().nullable(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* Returns the legacy permissions gating an app, with the role names
currently holding each permission, paginated. Admin only.
* @summary List permission restrictions on an app
*/
export const GetAdminAppDependentRestrictionsParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminAppDependentRestrictionsQueryLimitDefault = 50;
export const getAdminAppDependentRestrictionsQueryLimitMax = 200;
export const getAdminAppDependentRestrictionsQueryOffsetDefault = 0;
export const getAdminAppDependentRestrictionsQueryOffsetMin = 0;
export const GetAdminAppDependentRestrictionsQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminAppDependentRestrictionsQueryLimitMax)
.default(getAdminAppDependentRestrictionsQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminAppDependentRestrictionsQueryOffsetMin)
.default(getAdminAppDependentRestrictionsQueryOffsetDefault),
});
export const GetAdminAppDependentRestrictionsResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
name: zod.string(),
descriptionAr: zod.string().nullable(),
descriptionEn: zod.string().nullable(),
roles: zod
.array(zod.string())
.describe("Names of roles currently holding this permission."),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* Returns every recorded open for the app newest-first, paginated.
Unlike /stats/admin/app-opens/by-app/{appId}, this endpoint does
not apply a date range so the badge total and the list match.
* @summary List recent opens for an app (no time filter)
*/
export const GetAdminAppDependentOpensParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminAppDependentOpensQueryLimitDefault = 50;
export const getAdminAppDependentOpensQueryLimitMax = 200;
export const getAdminAppDependentOpensQueryOffsetDefault = 0;
export const getAdminAppDependentOpensQueryOffsetMin = 0;
export const GetAdminAppDependentOpensQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminAppDependentOpensQueryLimitMax)
.default(getAdminAppDependentOpensQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminAppDependentOpensQueryOffsetMin)
.default(getAdminAppDependentOpensQueryOffsetDefault),
});
export const GetAdminAppDependentOpensResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
createdAt: zod.coerce.date(),
userId: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
avatarUrl: zod.string().nullable(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* Returns the service orders behind the inline "<n> orders" badge
on the admin Services row, paginated, newest-first. Admin only.
* @summary List orders placed against a service
*/
export const GetAdminServiceDependentOrdersParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminServiceDependentOrdersQueryLimitDefault = 50;
export const getAdminServiceDependentOrdersQueryLimitMax = 200;
export const getAdminServiceDependentOrdersQueryOffsetDefault = 0;
export const getAdminServiceDependentOrdersQueryOffsetMin = 0;
export const GetAdminServiceDependentOrdersQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminServiceDependentOrdersQueryLimitMax)
.default(getAdminServiceDependentOrdersQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminServiceDependentOrdersQueryOffsetMin)
.default(getAdminServiceDependentOrdersQueryOffsetDefault),
});
export const GetAdminServiceDependentOrdersResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
status: zod.string(),
notes: zod.string().nullable(),
createdAt: zod.coerce.date(),
userId: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* @summary List notes owned by a user
*/
export const GetAdminUserDependentNotesParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminUserDependentNotesQueryLimitDefault = 50;
export const getAdminUserDependentNotesQueryLimitMax = 200;
export const getAdminUserDependentNotesQueryOffsetDefault = 0;
export const getAdminUserDependentNotesQueryOffsetMin = 0;
export const GetAdminUserDependentNotesQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminUserDependentNotesQueryLimitMax)
.default(getAdminUserDependentNotesQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminUserDependentNotesQueryOffsetMin)
.default(getAdminUserDependentNotesQueryOffsetDefault),
});
export const GetAdminUserDependentNotesResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
title: zod.string(),
isPinned: zod.boolean(),
isArchived: zod.boolean(),
updatedAt: zod.coerce.date(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* @summary List service orders placed by a user
*/
export const GetAdminUserDependentOrdersParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminUserDependentOrdersQueryLimitDefault = 50;
export const getAdminUserDependentOrdersQueryLimitMax = 200;
export const getAdminUserDependentOrdersQueryOffsetDefault = 0;
export const getAdminUserDependentOrdersQueryOffsetMin = 0;
export const GetAdminUserDependentOrdersQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminUserDependentOrdersQueryLimitMax)
.default(getAdminUserDependentOrdersQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminUserDependentOrdersQueryOffsetMin)
.default(getAdminUserDependentOrdersQueryOffsetDefault),
});
export const GetAdminUserDependentOrdersResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
status: zod.string(),
notes: zod.string().nullable(),
createdAt: zod.coerce.date(),
serviceId: zod.number(),
serviceNameAr: zod.string(),
serviceNameEn: zod.string(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* @summary List conversations created by a user
*/
export const GetAdminUserDependentConversationsParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminUserDependentConversationsQueryLimitDefault = 50;
export const getAdminUserDependentConversationsQueryLimitMax = 200;
export const getAdminUserDependentConversationsQueryOffsetDefault = 0;
export const getAdminUserDependentConversationsQueryOffsetMin = 0;
export const GetAdminUserDependentConversationsQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminUserDependentConversationsQueryLimitMax)
.default(getAdminUserDependentConversationsQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminUserDependentConversationsQueryOffsetMin)
.default(getAdminUserDependentConversationsQueryOffsetDefault),
});
export const GetAdminUserDependentConversationsResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
nameAr: zod.string().nullable(),
nameEn: zod.string().nullable(),
isGroup: zod.boolean(),
createdAt: zod.coerce.date(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});
/**
* @summary List messages sent by a user
*/
export const GetAdminUserDependentMessagesParams = zod.object({
id: zod.coerce.number(),
});
export const getAdminUserDependentMessagesQueryLimitDefault = 50;
export const getAdminUserDependentMessagesQueryLimitMax = 200;
export const getAdminUserDependentMessagesQueryOffsetDefault = 0;
export const getAdminUserDependentMessagesQueryOffsetMin = 0;
export const GetAdminUserDependentMessagesQueryParams = zod.object({
limit: zod.coerce
.number()
.min(1)
.max(getAdminUserDependentMessagesQueryLimitMax)
.default(getAdminUserDependentMessagesQueryLimitDefault),
offset: zod.coerce
.number()
.min(getAdminUserDependentMessagesQueryOffsetMin)
.default(getAdminUserDependentMessagesQueryOffsetDefault),
});
export const GetAdminUserDependentMessagesResponse = zod.object({
items: zod.array(
zod.object({
id: zod.number(),
content: zod.string(),
kind: zod.string(),
createdAt: zod.coerce.date(),
conversationId: zod.number(),
conversationNameAr: zod.string().nullable(),
conversationNameEn: zod.string().nullable(),
conversationIsGroup: zod.boolean(),
}),
),
totalCount: zod.number(),
limit: zod.number(),
offset: zod.number(),
nextOffset: zod.number().nullable(),
});