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
+471
View File
@@ -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: