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:
@@ -2465,6 +2465,275 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/apps/{id}/dependents/groups:
|
||||
get:
|
||||
operationId: getAdminAppDependentGroups
|
||||
tags: [apps]
|
||||
summary: List groups granting access to an app
|
||||
description: |
|
||||
Returns the groups behind the inline "<n> groups" badge on the
|
||||
admin Apps row, paginated. Admin only.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of groups granting the app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDependentGroupsPage"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/apps/{id}/dependents/restrictions:
|
||||
get:
|
||||
operationId: getAdminAppDependentRestrictions
|
||||
tags: [apps]
|
||||
summary: List permission restrictions on an app
|
||||
description: |
|
||||
Returns the legacy permissions gating an app, with the role names
|
||||
currently holding each permission, paginated. Admin only.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of restrictions on the app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDependentRestrictionsPage"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/apps/{id}/dependents/opens:
|
||||
get:
|
||||
operationId: getAdminAppDependentOpens
|
||||
tags: [apps]
|
||||
summary: List recent opens for an app (no time filter)
|
||||
description: |
|
||||
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.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of opens
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppDependentOpensPage"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/services/{id}/dependents/orders:
|
||||
get:
|
||||
operationId: getAdminServiceDependentOrders
|
||||
tags: [services]
|
||||
summary: List orders placed against a service
|
||||
description: |
|
||||
Returns the service orders behind the inline "<n> orders" badge
|
||||
on the admin Services row, paginated, newest-first. Admin only.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of orders for the service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceDependentOrdersPage"
|
||||
"404":
|
||||
description: Service not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/notes:
|
||||
get:
|
||||
operationId: getAdminUserDependentNotes
|
||||
tags: [users]
|
||||
summary: List notes owned by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of notes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentNotesPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/orders:
|
||||
get:
|
||||
operationId: getAdminUserDependentOrders
|
||||
tags: [users]
|
||||
summary: List service orders placed by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of orders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentOrdersPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/conversations:
|
||||
get:
|
||||
operationId: getAdminUserDependentConversations
|
||||
tags: [users]
|
||||
summary: List conversations created by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of conversations
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentConversationsPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/users/{id}/dependents/messages:
|
||||
get:
|
||||
operationId: getAdminUserDependentMessages
|
||||
tags: [users]
|
||||
summary: List messages sent by a user
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, default: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: Paged list of messages
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDependentMessagesPage"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthStatus:
|
||||
@@ -4098,6 +4367,208 @@ components:
|
||||
- nextOffset
|
||||
- opens
|
||||
|
||||
# ===== Dependent-item drill-ins =====
|
||||
# Each "<n> X" badge on the admin Apps / Services / Users rows is now
|
||||
# clickable; the click loads one of the schemas below. They share the
|
||||
# same envelope (totalCount + limit/offset/nextOffset) so the frontend
|
||||
# can use a single LoadMoreSection for them all.
|
||||
|
||||
AppDependentGroupItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
descriptionAr: { type: ["string", "null"] }
|
||||
descriptionEn: { type: ["string", "null"] }
|
||||
required: [id, name, descriptionAr, descriptionEn]
|
||||
|
||||
AppDependentGroupsPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppDependentGroupItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
AppDependentRestrictionItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
descriptionAr: { type: ["string", "null"] }
|
||||
descriptionEn: { type: ["string", "null"] }
|
||||
roles:
|
||||
type: array
|
||||
description: Names of roles currently holding this permission.
|
||||
items: { type: string }
|
||||
required: [id, name, descriptionAr, descriptionEn, roles]
|
||||
|
||||
AppDependentRestrictionsPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppDependentRestrictionItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
AppDependentOpenItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
createdAt: { type: string, format: date-time }
|
||||
userId: { type: integer }
|
||||
username: { type: string }
|
||||
displayNameAr: { type: ["string", "null"] }
|
||||
displayNameEn: { type: ["string", "null"] }
|
||||
avatarUrl: { type: ["string", "null"] }
|
||||
required: [id, createdAt, userId, username, displayNameAr, displayNameEn, avatarUrl]
|
||||
|
||||
AppDependentOpensPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppDependentOpenItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
ServiceDependentOrderItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
status: { type: string }
|
||||
notes: { type: ["string", "null"] }
|
||||
createdAt: { type: string, format: date-time }
|
||||
userId: { type: integer }
|
||||
username: { type: string }
|
||||
displayNameAr: { type: ["string", "null"] }
|
||||
displayNameEn: { type: ["string", "null"] }
|
||||
required: [id, status, notes, createdAt, userId, username, displayNameAr, displayNameEn]
|
||||
|
||||
ServiceDependentOrdersPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ServiceDependentOrderItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentNoteItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
title: { type: string }
|
||||
isPinned: { type: boolean }
|
||||
isArchived: { type: boolean }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
required: [id, title, isPinned, isArchived, updatedAt]
|
||||
|
||||
UserDependentNotesPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentNoteItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentOrderItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
status: { type: string }
|
||||
notes: { type: ["string", "null"] }
|
||||
createdAt: { type: string, format: date-time }
|
||||
serviceId: { type: integer }
|
||||
serviceNameAr: { type: string }
|
||||
serviceNameEn: { type: string }
|
||||
required: [id, status, notes, createdAt, serviceId, serviceNameAr, serviceNameEn]
|
||||
|
||||
UserDependentOrdersPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentOrderItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentConversationItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
nameAr: { type: ["string", "null"] }
|
||||
nameEn: { type: ["string", "null"] }
|
||||
isGroup: { type: boolean }
|
||||
createdAt: { type: string, format: date-time }
|
||||
required: [id, nameAr, nameEn, isGroup, createdAt]
|
||||
|
||||
UserDependentConversationsPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentConversationItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
UserDependentMessageItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
content: { type: string }
|
||||
kind: { type: string }
|
||||
createdAt: { type: string, format: date-time }
|
||||
conversationId: { type: integer }
|
||||
conversationNameAr: { type: ["string", "null"] }
|
||||
conversationNameEn: { type: ["string", "null"] }
|
||||
conversationIsGroup: { type: boolean }
|
||||
required: [id, content, kind, createdAt, conversationId, conversationNameAr, conversationNameEn, conversationIsGroup]
|
||||
|
||||
UserDependentMessagesPage:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserDependentMessageItem"
|
||||
totalCount: { type: integer }
|
||||
limit: { type: integer }
|
||||
offset: { type: integer }
|
||||
nextOffset: { type: ["integer", "null"] }
|
||||
required: [items, totalCount, limit, offset, nextOffset]
|
||||
|
||||
TopUserItem:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in New Issue
Block a user