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:
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user